diff --git a/CLAUDE.md b/CLAUDE.md
index 4fa022e..52e7a1e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -49,7 +49,11 @@ extraction, Meilisearch indexing with scheduled refresh, and four MCP tools
│ ├── ContentSourceStrategy.java # per-source-type discover/fetch seam
│ ├── WebsiteSourceStrategy.java # website strategy: crawler + fetcher + extractor
│ ├── FetchOutcome.java # INDEX/UNCHANGED/DELETE/SKIP + optional document
-│ └── SourceStrategyRegistry.java # selects a strategy by ContentSource.type()
+│ ├── SourceStrategyRegistry.java # selects a strategy by ContentSource.type()
+│ ├── ContentIndexer.java # orchestrates discover→diff→fetch→upsert/delete
+│ ├── IndexReport.java # per-pass counters
+│ ├── ContentIndexStore.java # index read/write seam (StoredDocument)
+│ └── MeilisearchContentIndexStore.java # ContentIndexStore backed by MeilisearchClient
├── src/main/resources/application.yaml # datasource, JPA, OAuth2, MCP, Meilisearch, content config
├── src/test/java/com/openelements/content/ # behavior tests (context, MCP enabled/disabled, search-down, jsoup)
└── docs/
@@ -95,7 +99,13 @@ categories/preview image/locale) from OpenGraph/Article ``, JSON-LD, and `
wires crawler → fetcher → extractor and maps the fetch result to a `FetchOutcome`
(`INDEX`/`UNCHANGED`/`DELETE`/`SKIP`); `SourceStrategyRegistry` selects the strategy by
`ContentSource.type()`, so a future `git` strategy (spec 016) plugs in as a bean with no indexer
-changes.
+changes. `ContentIndexer` is the orchestration engine: discover → diff discovered `lastmod` against
+the state read from the index (`ContentIndexStore`; the index *is* the state) → fetch only new/changed
+items via the strategy → batch-upsert, and delete documents that 404 or vanished from discovery.
+`MeilisearchContentIndexStore` implements the store over `MeilisearchClient` (paged `multiSearch` to
+read state, `addDocuments`+`waitForTask` to upsert, `deleteDocument`), since the library's
+`BatchWriter` is package-private. The indexer is the reusable engine for the bootstrap step (009) and
+refresh scheduler (010).
> **Key gotcha:** the library ships no Spring Boot auto-configuration and couples MCP to a JPA
> datasource, so `@Import({ McpConfiguration, SearchConfig })` alone does **not** boot. See
diff --git a/docs/specs/008-content-indexer/steps.md b/docs/specs/008-content-indexer/steps.md
new file mode 100644
index 0000000..ee53661
--- /dev/null
+++ b/docs/specs/008-content-indexer/steps.md
@@ -0,0 +1,61 @@
+# Implementation Steps: Content Indexer
+
+## Step 1: `IndexReport`
+
+- [x] Record `IndexReport(discovered, upserted, unchanged, deleted, skipped)`
+
+**Related behaviors:** all (report counters)
+
+---
+
+## Step 2: `ContentIndexStore` seam
+
+- [x] Interface `loadState(source) → id→StoredDocument`, `upsert(docs) → count`, `delete(id)`; nested `StoredDocument(url, lastmod)`
+- [x] `MeilisearchContentIndexStore` production impl: paged filtered `multiSearch` (testable `parseHits`), batched `addDocuments` + `waitForTask` (BatchWriter is package-private), `deleteDocument`; gated on `meilisearch.enabled`
+
+**Related behaviors:** underpins all (diff/upsert/delete)
+
+---
+
+## Step 3: `ContentIndexer`
+
+- [x] `@Component` (gated on `meilisearch.enabled`) taking `SourceStrategyRegistry` + `ContentIndexStore`
+- [x] `indexSource`: discover → load stored state → per item: unchanged (lastmod equal) skip; else strategy.fetch → INDEX(collect)/UNCHANGED/DELETE/SKIP; batch upsert; delete stored-but-not-discovered
+- [x] `streamAllDocuments`: lazy discover→fetch→INDEX map stream for bootstrap
+- [x] Idempotent (upsert by stable id); fault-tolerant (SKIP one, continue)
+
+**Related behaviors:** new/unchanged/changed/304/vanished/404/idempotent/skip/dedupe/stream
+
+---
+
+## Step 4: Tests
+
+- [x] `ContentIndexerTest` (10) — in-memory store + stub strategy: every behavior
+- [x] `MeilisearchContentIndexStoreTest` (3) — `parseHits` parsing (hits, empty, missing id)
+
+**Acceptance criteria:**
+- [x] All tests pass (`mvn test`); build green
+
+---
+
+## Behavior Coverage
+
+| Scenario | Layer | Covered in Step |
+|----------|-------|-----------------|
+| New pages are upserted | Backend | Step 4 (`newPagesAreUpserted`) |
+| Unchanged pages are skipped | Backend | Step 4 (`unchangedPagesAreSkipped`) |
+| Changed page is re-fetched and upserted | Backend | Step 4 (`changedPageIsReFetched`) |
+| Conditional GET short-circuits a lastmod false-positive | Backend | Step 4 (`conditionalGetShortCircuits`) |
+| Vanished URL is deleted | Backend | Step 4 (`vanishedUrlIsDeleted`) |
+| 404 on fetch deletes the document | Backend | Step 4 (`notFoundDeletesDocument`) |
+| Re-running is idempotent | Backend | Step 4 (`reRunIsIdempotent`) |
+| One failing page does not abort the batch | Backend | Step 4 (`oneFailingPageIsSkipped`) |
+| Stable id prevents duplicates | Backend | Step 4 (`stableIdPreventsDuplicates`) |
+| streamAllDocuments yields every in-scope document | Backend | Step 4 (`streamAllDocumentsYieldsAll`) |
+
+All scenarios are backend; there is no frontend in this spec.
+
+## Notes / deviations
+
+- The design suggested `BatchWriter.write(...)`, but `BatchWriter` is package-private in `spring-services` and not reusable from this package. Upsert uses `MeilisearchClient.addDocuments` in batches with `waitForTask`.
+- A `ContentIndexStore` seam was introduced (not in the original design) to encapsulate the Meilisearch read/write and make the orchestration unit-testable without a live instance. The production impl's `parseHits` is unit-tested; the `multiSearch` paging / `addDocuments` batching is thin delegation exercised end-to-end by specs 009/010 against real Meilisearch.
diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md
index 40bf92e..e732467 100644
--- a/docs/specs/INDEX.md
+++ b/docs/specs/INDEX.md
@@ -13,7 +13,7 @@ roadmap step, to be implemented sequentially (each builds on the previous).
| 005 | 005-page-fetcher | Page fetcher | backend, crawler | `PageFetcher` — robust HTTP fetch (ETag/IMS, rate limit, retry) | #9 | done |
| 006 | 006-content-extractor | Content extractor | backend, crawler | `ContentExtractor` — jsoup content + metadata extraction | #11 | done |
| 007 | 007-source-strategy | Source strategy | backend, architecture | `ContentSourceStrategy` interface + `WebsiteSourceStrategy` | #13 | done |
-| 008 | 008-content-indexer | Content indexer | backend, search, crawler | `ContentIndexer` — orchestration, diff, upsert/delete | — | open |
+| 008 | 008-content-indexer | Content indexer | backend, search, crawler | `ContentIndexer` — orchestration, diff, upsert/delete | #15 | done |
| 009 | 009-content-bootstrap-step | Content bootstrap step | backend, search | `ContentBootstrapStep` — initial reindex via `SearchIndexBootstrapStep` | — | open |
| 010 | 010-content-refresh-scheduler | Content refresh scheduler | backend, scheduling | `ContentRefreshScheduler` — `@Scheduled` incremental re-crawl | — | open |
| 011 | 011-content-search-service | Content search service | backend, search | `ContentSearchService` — `multiSearch` + highlighting facade | — | open |
diff --git a/src/main/java/com/openelements/content/ContentIndexStore.java b/src/main/java/com/openelements/content/ContentIndexStore.java
new file mode 100644
index 0000000..dc8d642
--- /dev/null
+++ b/src/main/java/com/openelements/content/ContentIndexStore.java
@@ -0,0 +1,47 @@
+package com.openelements.content;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The persistence seam the {@link ContentIndexer} uses to read and write index state.
+ *
+ *
The Meilisearch content index is the state: {@link #loadState(String)} returns the
+ * stored change-marker per document so the indexer can diff without a separate datastore. Isolating
+ * these three operations keeps the indexer's orchestration logic testable with an in-memory
+ * implementation, independent of a running Meilisearch.
+ */
+public interface ContentIndexStore {
+
+ /**
+ * Loads the currently-indexed documents for a source.
+ *
+ * @param source the source id
+ * @return a map of document id → {@link StoredDocument} (its URL and change marker)
+ */
+ Map loadState(String source);
+
+ /**
+ * Upserts documents (keyed by their {@code id}); existing documents with the same id are replaced.
+ *
+ * @param documents document attribute maps (from {@link ContentDocument#toMap()})
+ * @return the number of documents written
+ */
+ int upsert(List