Adds technical design docs for the foundation of the doc-centric ingest pivot: - 202 — Document lifecycle state machine - 203 — Per (document, store) ingestion state - 204 — Auto-detect Stale state via chunk content hash - 205 — Audit trail for chunk edits (chunks → first-class entity) - 206 — Migration of existing documents to the new model Status: Accepted on all five. Each doc spells out the domain entities, persistence schema, services orchestration, API contract, alternatives considered, risks, and testing strategy. ADR placeholders called out where load-bearing decisions warrant a follow-up document. Refs #202 #203 #204 #205 #206
15 KiB
Design: Per (document, store) ingestion state
- Issue: #203
- Title on issue: [FEATURE] Track ingestion state per (document, store) pair
- Author: Pier-Jean Malandrino
- Date: 2026-04-29
- Status: Accepted
- Target milestone: 0.6.0 — Doc-centric ingest
- Impacted layers: backend: domain · persistence · services · api · infra · frontend: features/ingestion · features/document · shared
- Audit dimensions likely touched: Hexagonal Architecture · DDD · Decoupling · Tests · Documentation
- ADR spawned?: ADR-NNN — "Multiple vector stores as first-class entities"
1. Problem
In the current architecture, "the vector store" is a single OpenSearch index hard-coded to docling-studio-chunks. Documents either are or are not indexed in that one place. The new doc-centric model assumes documents can live in multiple stores simultaneously — rh-corpus-v3, legal-v1, a snapshot for a customer A/B — and each (doc, store) pair has its own state.
This is also where the killer flow lives: a customer reports bad answers from a specific store; the eng opens the doc, sees that it is Ingested in rh-corpus-v3 and Stale in rh-corpus-v2, fixes the chunks, and re-ingests the right one. Without a per-pair state, that flow is impossible.
2. Goals
- Introduce a
Storeentity in the domain (id, name, kind, config, embedder). - Introduce a
DocumentStoreLinkentity tying a document to a store with its own state and metadata. - States on the link:
Ingested,Stale,Failed. (NoUploaded/Parsed/Chunked— those are doc-level only.) - The
Document.lifecycle_state(#202) aggregates the per-store states by a documented rule. - API exposes the per-store list on the document DTO.
- One default
Storerow is seeded on first migration so existing single-index deployments keep working.
3. Non-goals
- Auto-stale detection logic — that is #204; this issue ships the schema slot for the content hash but not the detection.
- Multi-tenant isolation between stores — the existing tenant model (if any) is not changed; stores are global today.
- A UI to create/manage stores — that is a follow-up. For 0.6.0 we ship the data model and the seed; a single store is enough for the killer flow on a single tenant.
- Cross-doc bulk re-ingest — that is #213; this issue exposes the per-link state needed by it.
4. Context & constraints
Existing code surface
document-parser/domain/ports.py—VectorStoreprotocol (line 124).document-parser/infra/opensearch_store.py—OpenSearchStoreadapter.document-parser/services/ingestion_service.py—IngestionService.ingest()andensure_index().document-parser/persistence/database.py— schema + migrations.document-parser/api/ingestion.py— endpoints.frontend/src/features/ingestion/— Pinia store + API client.
Hexagonal Architecture constraints (backend)
StoreandDocumentStoreLinkare domain entities (domain/models.py). Pure data + invariants. No ORM concern.StoreRepositoryandDocumentStoreLinkRepositoryare ports (domain/ports.py).- aiosqlite adapters live in
persistence/store_repo.pyandpersistence/document_store_link_repo.py. - The existing
VectorStoreport stays — it represents the technology (OpenSearch). The newStoreentity represents the logical store (a named, configurable target). OneVectorStoreadapter can serve manyStoreentities by namespacing the index name.
Deployment modes
- Single
OpenSearchStoreadapter handles all stores via per-storeindex_name = f"docling-studio-{store.slug}". Existing default index gets migrated todocling-studio-default(with a redirect alias to keep backwards-compatible reads). - HF Space deployment ships with the same one-store seed.
Hard constraints
- Existing OpenSearch index must remain readable during migration; reads against the legacy name are aliased.
- API is additive: existing
/api/ingestion/{analysis_id}keeps working with an implicit default-store target until the UI explicitly picks one (#222). - Tests must not require a real OpenSearch instance for the link layer — repo tests stub the adapter.
5. Proposed design
5.1 Domain
document-parser/domain/value_objects.py:
class StoreKind(StrEnum):
OPENSEARCH = "opensearch"
# future: PINECONE, QDRANT, …
class DocumentStoreLinkState(StrEnum):
INGESTED = "Ingested"
STALE = "Stale"
FAILED = "Failed"
document-parser/domain/models.py:
@dataclass
class Store:
id: str
name: str # "rh-corpus-v3"
slug: str # "rh-corpus-v3" (URL-safe; usually = name)
kind: StoreKind
embedder: str # e.g. "bge-m3" — record the embedder used for this store
config: dict # adapter-specific config (index_name override, etc.)
created_at: datetime
is_default: bool
@dataclass
class DocumentStoreLink:
id: str
document_id: str
store_id: str
state: DocumentStoreLinkState
chunkset_hash: str | None # set by #204 on push
last_push_at: datetime | None
last_run_id: str | None # FK to a future runs table; nullable in 0.6.0
error_message: str | None # populated on FAILED state
def mark_ingested(self, *, hash_: str, at: datetime, run_id: str | None) -> None: ...
def mark_stale(self, *, at: datetime) -> None: ...
def mark_failed(self, *, at: datetime, error: str) -> None: ...
Aggregation rule for Document.lifecycle_state (defined as a pure function in domain/lifecycle_aggregation.py):
if any link state == FAILED → Document is FAILED
elif any link state == STALE → Document is STALE
elif any link state == INGESTED → Document is INGESTED
elif chunks present (state Chunked) → Document keeps its current state
else → keep current state
The aggregation runs as a side effect of any link write. The doc's own state column (#202) is the materialized result; it is not recomputed at read time.
5.2 Persistence
CREATE TABLE IF NOT EXISTS stores (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL,
embedder TEXT NOT NULL,
config TEXT NOT NULL DEFAULT '{}', -- JSON
is_default INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS document_store_links (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
store_id TEXT NOT NULL REFERENCES stores(id) ON DELETE CASCADE,
state TEXT NOT NULL,
chunkset_hash TEXT,
last_push_at TEXT,
last_run_id TEXT,
error_message TEXT,
UNIQUE (document_id, store_id)
);
CREATE INDEX IF NOT EXISTS idx_dsl_doc ON document_store_links(document_id);
CREATE INDEX IF NOT EXISTS idx_dsl_store ON document_store_links(store_id);
CREATE INDEX IF NOT EXISTS idx_dsl_state ON document_store_links(state);
Seed migration inserts one row in stores:
INSERT OR IGNORE INTO stores (id, name, slug, kind, embedder, is_default, created_at)
VALUES ('default', 'default', 'default', 'opensearch',
'<env: DEFAULT_EMBEDDER, fallback bge-m3>',
1, datetime('now'));
The OpenSearch index name for slug=default is docling-studio-default. To keep existing data readable, an alias migration in OpenSearchStore.ensure_index() adds docling-studio-chunks as a read-only alias to docling-studio-default if the legacy index exists with rows. Operators with non-trivial data trigger a one-shot reindex via #206.
5.3 Infra adapters
OpenSearchStore is parameterised by store slug:
class OpenSearchStore(VectorStore):
def __init__(self, client, index_prefix: str = "docling-studio") -> None: ...
def _index_for(self, store_slug: str) -> str:
return f"{self.index_prefix}-{store_slug}"
Calls become index_chunks(store_slug, doc_id, chunks, embeddings) etc. The protocol in domain/ports.py is updated to take store_slug as an explicit argument.
5.4 Services
New: StoreService (document-parser/services/store_service.py) — list, get, create (admin only — locked behind a future flag; for 0.6.0 only seeded rows exist).
IngestionService.ingest(analysis_id, store_slug = "default"):
- Read chunks from
analysis_jobs.chunks_json. - Compute embeddings.
- Call adapter
index_chunks(store_slug, ...). - Upsert link via
DocumentStoreLinkRepository.upsert(doc_id, store_id, state=Ingested, chunkset_hash=..., at=now). - Recompute document aggregate state via the rule in §5.1.
If any step fails: link is marked Failed with the error; doc state aggregates to Failed.
5.5 API
schemas.py:
class StoreLinkResponse(BaseModel):
storeId: str
storeName: str
state: str # DocumentStoreLinkState value
chunksetHash: str | None
lastPushAt: datetime | None
lastRunId: str | None
class DocumentResponse(BaseModel):
... # from #202
stores: list[StoreLinkResponse] = Field(default_factory=list)
stores is a read-side aggregate computed by DocumentService.find_by_id() joining the link table.
New endpoint GET /api/stores returns the list of stores (id, name, slug, embedder). Used by #222's target picker.
5.6 Frontend — feature module
frontend/src/features/ingestion/—Ingestion.run()accepts an optionalstoreSlug; defaults to "default" if not supplied.- New
frontend/src/features/stores/— Pinia store, API client, types. frontend/src/features/document/api.ts—Documenttype gainsstores: StoreLink[].
5.7 Cross-cutting
- Feature flag: none.
- i18n:
stores.state.<state>keys inshared/i18n.ts. - New env var documented in
.env.example:DEFAULT_EMBEDDER(existing implicitly; now formalised in the seed).
6. Alternatives considered
Alternative A — One row per (doc, store) embedded in the analysis job
- Summary: Add a JSON column
stores_jsononanalysis_jobs. - Why not: Conflates an analysis attempt with a long-lived ingestion link. Re-analysis would erase per-store state. The relational shape is necessary for filters, indexes, and idempotent updates.
Alternative B — One adapter per store (parallel VectorStore instances)
- Summary: Hold a registry of
VectorStoreadapters keyed by store id. - Why not: Duplicates connection pools, breaks singleton pattern in FastAPI's dependency wiring, and forces N OpenSearch clients for what is logically one client serving N indexes. Parameterising the existing single adapter is cheaper.
7. API & data contract
Endpoints
| Method | Path | Request | Response | Breaking? |
|---|---|---|---|---|
| GET | /api/documents/{id} |
— | now includes stores: StoreLinkResponse[] |
No (additive) |
| GET | /api/stores |
— | StoreResponse[] |
No (new) |
| POST | /api/ingestion/{analysis_id} |
{ "storeSlug": "default" } (optional) |
unchanged | No (additive) |
Persistence schema
See §5.2.
Env vars / config
| Name | Default | Allowed | Notes |
|---|---|---|---|
DEFAULT_EMBEDDER |
bge-m3 |
any registered embedder slug | recorded on the seeded default store |
DOCLING_STUDIO_INDEX_PREFIX |
docling-studio |
URL-safe slug | adapter-level prefix for OpenSearch indexes |
Breaking changes
Additive.
8. Risks & mitigations
| Risk | Audit dimension | Likelihood | Impact | How we notice | Mitigation / rollback |
|---|---|---|---|---|---|
| Existing OpenSearch data invisible after rename | Decoupling | Medium | High | Smoke test on staging | Read alias docling-studio-chunks → docling-studio-default set in ensure_index(); explicit reindex documented in runbook |
| Multiple stores with conflicting embedders mixed at search time | Security/Performance | Low | Medium | Mismatched dim error from OpenSearch | Embedder is recorded per store; queries route by store; cross-store search is out of scope for 0.6.0 |
| Link table grows large over time on prod corpus | Performance | Medium | Medium | Slow /api/documents/{id} join |
Index on document_id and store_id; pagination on the doc list (#211) |
| Aggregation rule drifts from per-store reality on partial writes | DDD | Medium | High | Tests fail | Aggregation runs in the same transaction as the link write |
9. Testing strategy
Backend — pytest
- Unit (domain):
test_store_link_transitions.py,test_lifecycle_aggregation.py(table-driven over per-store state combinations). - Persistence:
test_store_repo.py,test_document_store_link_repo.py(round-trip; UNIQUE constraint enforced; cascade delete works). - Services / integration:
test_ingestion_service_with_store.py(default store path),test_ingestion_service_two_stores.py(push to two stores → two links). - Architecture: import-boundary test ensures
domain/does not importinfra/opensearch_store.py.
Frontend — Vitest
features/stores/api.test.ts— list endpoint round-trip.features/document/store.test.ts—Document.storespopulated from API.
E2E — Karate UI
Out of scope here. Lands with #211 / #218.
Manual QA
- Boot.
GET /api/storesreturns the seededdefault. - Upload a doc, run ingestion →
GET /api/documents/{id}returnsstores[0].state == "Ingested". - Inspect SQLite:
document_store_linkshas the row.
10. Rollout & observability
Release branch
release/0.6.0.
Feature flag
None for the data; #222 will gate the multi-store UI.
Observability
- Log
store_link_changedwithdoc_id,store_id,from,to,at. - Counter (Prometheus, future):
ingestion_links_total{state}. Optional in 0.6.0.
Rollback plan
The migration is additive. To roll back the code: revert; the unused tables stay empty for new docs but contain rows for already-pushed docs — harmless (no read path uses them after revert). For full cleanup, a follow-up migration drops the two tables.
11. Open questions
- Should
Store.configbe typed in the domain (via a discriminated union onkind)? Decision for 0.6.0: keep as opaquedict; introduce a typed wrapper when we add a secondkind. - Cross-store search (single query → many stores) — explicitly punted to a later release.
12. References
- Issue: https://github.com/scub-france/Docling-Studio/issues/203
- Related issues: #202 (lifecycle), #204 (hash), #205 (audit), #206 (migration), #211 (library), #213 (bulk push), #222 (push UI), #223 (diff-aware ingest)
- ADRs: ADR — "Multiple vector stores as first-class entities" (to be drafted alongside the implementation PR)
- Project docs:
- Architecture:
docs/architecture.md - Coding standards:
docs/architecture/coding-standards.md - ADR guide:
docs/architecture/adr-guide.md
- Architecture: