Compare commits

..

3 commits

Author SHA1 Message Date
Pier-Jean Malandrino
c878ee5f3b feat(#202): introduce Document lifecycle state machine
Adds a first-class lifecycle state to every document, distinct from
AnalysisJob.status. The lifecycle describes the document as a whole and
is the foundation for the doc-centric pivot in 0.6.0.

Domain
- DocumentLifecycleState enum (Uploaded/Parsed/Chunked/Ingested/Stale/Failed)
- Document.lifecycle_state and lifecycle_state_at fields
- Document.transition_to() validates against a transition table
  (domain/lifecycle.py) and returns a DocumentLifecycleChanged event
- InvalidLifecycleTransitionError on disallowed transitions

Persistence
- ALTER TABLE documents to add the two columns (default 'Uploaded')
- New index idx_documents_lifecycle_state for filter perf
- _COLUMN_MIGRATIONS refactored to support multiple tables
- _POST_MIGRATION_DDL list for indexes on freshly-added columns
- SqliteDocumentRepository.update_lifecycle()

Services
- AnalysisService drives transitions on parse / chunk / re-chunk / fail
  via _transition_document(); idempotent and resilient (logs WARN and
  continues if a stale state is somehow encountered)

API
- DocumentResponse exposes lifecycleState + lifecycleStateAt
  (additive — existing 'status' field kept for backwards compat)

Frontend
- Document type extended with lifecycleState and lifecycleStateAt
- DocumentLifecycleState union literal mirroring the backend enum

Tests
- 24 new tests in test_lifecycle.py covering transitions, idempotency,
  invariant preservation, and event emission
- test_repos.py: round-trip + every-enum-value check + update_lifecycle
- test_chunking.py: rechunk path now mocks document_repo correctly

Refs #202
2026-04-29 15:34:25 +02:00
Pier-Jean Malandrino
f439d5e579 docs(design): E1 design docs for 0.6.0 doc-centric ingest
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
2026-04-29 15:24:16 +02:00
Pier-Jean Malandrino
17f59d4cee chore: gitignore local-only artifacts on release/0.6.0
Bootstrap commit for the doc-centric ingest release:
- .github/ISSUE_TEMPLATE/issue.md (local copy)
- docs/claude-code-commands.md (personal notes)
- docs/design/TEMPLATE.md (local design template)
- document-parser/tests/test_local_converter.py (local test scratch)
2026-04-29 15:13:43 +02:00
30 changed files with 2119 additions and 44 deletions

View file

@ -18,10 +18,6 @@
# Max upload file size in MB (default: 50, 0 = unlimited)
# MAX_FILE_SIZE_MB=50
# Nginx body size limit — nginx format (default: 200M, 0 = unlimited).
# Must be >= MAX_FILE_SIZE_MB. Backend MAX_FILE_SIZE_MB is the effective arbiter.
# NGINX_MAX_BODY_SIZE=200M
# Max pages per PDF (default: 0 = unlimited). Set to 20 for HF Spaces.
# MAX_PAGE_COUNT=0

View file

@ -27,7 +27,7 @@ jobs:
with:
python-version: "3.12"
cache: pip
cache-dependency-path: document-parser/requirements-test.txt
cache-dependency-path: document-parser/requirements.txt
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends poppler-utils
@ -35,8 +35,8 @@ jobs:
- name: Install pinned dependencies
run: |
pip install --upgrade pip
pip install -r requirements-test.txt
pip install httpx
pip install -r requirements.txt
pip install pytest pytest-asyncio httpx
- name: Upgrade docling to latest
id: versions

7
.gitignore vendored
View file

@ -49,3 +49,10 @@ profiles/
# E2E tests — Maven build outputs & Chrome user data
e2e/**/target/
# Local-only artifacts (release/0.6.0 bootstrap)
.github/ISSUE_TEMPLATE/issue.md
docs/claude-code-commands.md
docs/design/TEMPLATE.md
document-parser/tests/test_local_converter.py

View file

@ -4,12 +4,6 @@ All notable changes to Docling Studio will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
## [0.5.1] - 2026-04-30
### Fixed
- Nginx upload body cap raised from 5 MB to 200 MB (`NGINX_MAX_BODY_SIZE`, default `200M`); uploads larger than 5 MB no longer returned 413 before reaching the backend.
## [0.5.0] - 2026-04-28
### Added

View file

@ -24,11 +24,10 @@ FROM python:3.12-slim AS base
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
# System deps: poppler (pdf2image), nginx, gettext-base (envsubst for nginx template)
# System deps: poppler (pdf2image), nginx
RUN apt-get update && apt-get install -y --no-install-recommends \
poppler-utils \
nginx \
gettext-base \
&& rm -rf /var/lib/apt/lists/*
# Python deps (common)
@ -42,8 +41,8 @@ COPY document-parser/ .
# Frontend static files
COPY --from=frontend-build /build/dist /usr/share/nginx/html
# Nginx config (template stored outside sites-enabled to avoid nginx loading it raw)
COPY nginx.conf.template /etc/nginx/default.template
# Nginx config
COPY nginx.conf /etc/nginx/sites-enabled/default
# Non-root user
RUN useradd --create-home --shell /bin/bash appuser
@ -53,11 +52,10 @@ RUN mkdir -p /app/uploads /app/data && chown -R appuser:appuser /app
ENV UPLOAD_DIR=/app/uploads
ENV DB_PATH=/app/data/docling_studio.db
ENV NGINX_MAX_BODY_SIZE=200M
EXPOSE 3000
CMD ["sh", "-c", "envsubst '${NGINX_MAX_BODY_SIZE}' < /etc/nginx/default.template > /etc/nginx/sites-enabled/default && nginx && exec su appuser -c 'uvicorn main:app --host 127.0.0.1 --port 8000'"]
CMD ["sh", "-c", "nginx && exec su appuser -c 'uvicorn main:app --host 127.0.0.1 --port 8000'"]
# --- Remote: lightweight, delegates to Docling Serve ---
FROM base AS remote

View file

@ -210,7 +210,6 @@ All configuration is done via environment variables. See [`.env.example`](.env.e
| `BATCH_PAGE_SIZE` | `10` | Pages per batch (`0` = process all at once) |
| `MAX_FILE_SIZE_MB` | `50` | Maximum upload file size in MB (`0` = unlimited) |
| `MAX_PAGE_COUNT` | `0` | Maximum number of pages per document (`0` = unlimited) |
| `NGINX_MAX_BODY_SIZE` | `200M` | Nginx request body limit — nginx format (`200M`, `0` = unlimited). Must be ≥ `MAX_FILE_SIZE_MB`. |
| `RATE_LIMIT_RPM` | `100` | Max requests per minute per IP (`0` = disabled) |
## Upload Limits
@ -219,9 +218,8 @@ Docling Studio enforces configurable limits on uploaded documents to protect the
- **`MAX_FILE_SIZE_MB`** (default `50`) — rejects uploads exceeding this size. Validated at two levels: early `Content-Length` check and streaming byte count.
- **`MAX_PAGE_COUNT`** (default `0` = unlimited) — rejects documents with more pages than allowed. Useful on shared instances or Hugging Face Spaces to cap processing time.
- **`NGINX_MAX_BODY_SIZE`** (default `200M`) — nginx-level body cap, applied before the request reaches the backend. Defaults to `200M` so `MAX_FILE_SIZE_MB` is always the effective limit. Use nginx format (`50M`, `1G`, `0` for unlimited).
Both application limits are exposed in the `/api/health` endpoint so the frontend can display them to the user before upload. Set either to `0` to disable the corresponding check.
Both limits are exposed in the `/api/health` endpoint so the frontend can display them to the user before upload. Set either to `0` to disable the corresponding check.
## Ingestion Pipeline (opt-in)

View file

@ -117,8 +117,6 @@ services:
context: ./frontend
ports:
- "3000:80"
environment:
NGINX_MAX_BODY_SIZE: ${NGINX_MAX_BODY_SIZE:-200M}
depends_on:
- document-parser

View file

@ -0,0 +1,292 @@
# Design: Document lifecycle state machine
- **Issue:** #202
- **Title on issue:** [FEATURE] Introduce Document lifecycle state machine
- **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 · frontend: features/document · shared
- **Audit dimensions likely touched:** Hexagonal Architecture · DDD · Tests · Documentation
- **ADR spawned?:** no
---
## 1. Problem
Studio today tracks ingestion as a side-effect of an `AnalysisJob`. The only "state" a document carries is implicit — derived by joining the document with its latest analysis job (`PENDING`, `RUNNING`, `COMPLETED`, `FAILED`) and, separately, by checking whether OpenSearch holds rows for `doc_id`. There is no explicit, persisted lifecycle of the document itself.
This blocks the 0.6.0 doc-centric pivot: we cannot show a document library `/docs` with status badges, we cannot tell the user "this doc is ingested but stale on store X", and we cannot drive a chunks editor that knows whether its draft is committed. Every later feature in E3, E4, and E5 reads or transitions a document state.
This design introduces a first-class **document lifecycle** as a domain concept: a single canonical state on each document, validated transitions, persisted, and surfaced via the API.
## 2. Goals
- [ ] Add a `DocumentLifecycleState` enum to the domain with six states: `Uploaded`, `Parsed`, `Chunked`, `Ingested`, `Stale`, `Failed`.
- [ ] `Document` carries `lifecycle_state` (current state) + `lifecycle_state_at` (last transition timestamp).
- [ ] State transitions are validated in the domain layer; invalid transitions raise `InvalidLifecycleTransition`.
- [ ] State changes emit a domain event `DocumentLifecycleChanged(previous, current, at)`.
- [ ] Persisted via aiosqlite migration; existing rows default to `Uploaded` (refined by #206).
- [ ] Surfaced on the document API DTO (camelCase: `lifecycleState`, `lifecycleStateAt`).
## 3. Non-goals
- Per-`(doc, store)` state — that is **#203** and uses a different table.
- Auto-stale detection via hash — that is **#204**; this issue only declares the `Stale` state value.
- UI rendering of the badge — that is **#215**.
- Migrating production data — that is **#206**; this issue ships the schema + default value only.
- Refactoring `AnalysisJob.status`. The two concepts coexist: `AnalysisJob.status` describes **a single conversion attempt**; `Document.lifecycle_state` describes **the doc as a whole**.
## 4. Context & constraints
### Existing code surface
- `document-parser/domain/models.py``Document` dataclass (line 27), `AnalysisJob` (line 38), `AnalysisStatus` enum.
- `document-parser/domain/value_objects.py` — value objects.
- `document-parser/persistence/database.py``_MIGRATIONS` list and `_run_migrations()` checking PRAGMA `table_info()`.
- `document-parser/persistence/document_repo.py``DocumentRepository` (aiosqlite).
- `document-parser/api/documents.py` — REST endpoints.
- `document-parser/api/schemas.py` — Pydantic DTOs with `alias_generator=_to_camel`.
- `frontend/src/features/document/store.ts` and `api.ts`.
### Hexagonal Architecture constraints (backend)
- `DocumentLifecycleState` is a **value object** — it lives in `domain/value_objects.py` with zero imports from `api`/`persistence`/`infra`.
- The transition rules are domain logic — they live as methods on `Document` (or a small `DocumentLifecycle` policy module in `domain/`). No HTTP, no DB.
- `DocumentRepository` (the port) gains a write path for the new fields. The aiosqlite adapter is the only place that knows about the column.
- API layer translates the enum to camelCase string via Pydantic.
### Deployment modes
Both `latest-local` and `latest-remote` images use the same SQLite schema → migration applies to both. No HF Space-specific concern. No feature flag (the lifecycle is always on; only UI surfaces are flagged).
### Hard constraints
- SQLite schema additive only. No column drops, no rename. New column has a `DEFAULT` so existing rows do not break on read.
- API contract additive only. New fields appear; nothing is removed.
- `pages_json` stays snake_case (existing exception).
## 5. Proposed design
### 5.1 Domain
Add to `document-parser/domain/value_objects.py`:
```python
from enum import StrEnum
class DocumentLifecycleState(StrEnum):
UPLOADED = "Uploaded"
PARSED = "Parsed"
CHUNKED = "Chunked"
INGESTED = "Ingested"
STALE = "Stale"
FAILED = "Failed"
```
Allowed transitions (a directed graph; `* → Failed` always allowed):
```
Uploaded → Parsed | Failed
Parsed → Chunked | Failed
Chunked → Ingested | Chunked | Failed # re-chunking is allowed
Ingested → Stale | Chunked | Failed # re-ingest stays Ingested via 203
Stale → Ingested | Chunked | Failed
Failed → Uploaded | Parsed | Chunked # explicit retry sets the new target
```
`Stale` is set by the auto-detect logic (#204) — never reached as a result of a manual action.
Add to `document-parser/domain/models.py`:
```python
@dataclass
class Document:
...
lifecycle_state: DocumentLifecycleState = DocumentLifecycleState.UPLOADED
lifecycle_state_at: datetime | None = None
def transition_to(self, target: DocumentLifecycleState, *, now: datetime) -> "DocumentLifecycleChanged":
if not _is_allowed(self.lifecycle_state, target):
raise InvalidLifecycleTransition(self.lifecycle_state, target)
previous = self.lifecycle_state
self.lifecycle_state = target
self.lifecycle_state_at = now
return DocumentLifecycleChanged(self.id, previous, target, now)
```
`InvalidLifecycleTransition` lives in `domain/exceptions.py` (create if missing). `_is_allowed` is a pure function over a static transition table.
`DocumentLifecycleChanged` is a frozen dataclass in `domain/events.py`. No event bus is wired in 0.6.0 — the event is *returned* from the transition call so services can log / persist / publish later. This avoids introducing infra in this issue.
### 5.2 Persistence
Schema migration appended to `_MIGRATIONS` in `document-parser/persistence/database.py`:
```sql
ALTER TABLE documents ADD COLUMN lifecycle_state TEXT NOT NULL DEFAULT 'Uploaded';
ALTER TABLE documents ADD COLUMN lifecycle_state_at TEXT;
CREATE INDEX IF NOT EXISTS idx_documents_lifecycle_state ON documents(lifecycle_state);
```
The `_run_migrations()` PRAGMA check ensures idempotency — re-running on an already-migrated DB is a no-op. The index supports the `/docs` library filter by status (#212).
`DocumentRepository.save(doc)` and `update_lifecycle(doc_id, state, at)` are the two write paths. Read returns the columns directly populated into the dataclass.
### 5.3 Infra adapters
None. The lifecycle is database-only.
### 5.4 Services
`DocumentService` and `AnalysisService` are the two callers that drive transitions.
| Trigger | From | To | Caller |
|---|---|---|---|
| Upload completes | — | `Uploaded` | `DocumentService.upload()` |
| Parse succeeds | `Uploaded` (or `Failed` retry) | `Parsed` | `AnalysisService` |
| Chunking succeeds | `Parsed` (or `Chunked` re-chunk) | `Chunked` | `AnalysisService` |
| Ingestion succeeds | `Chunked` (or `Stale`) | `Ingested` | `IngestionService` (touched in #203) |
| Failure on any pipeline step | any | `Failed` | the failing service |
Transition calls are atomic with the underlying write (e.g., chunking writes `chunks_json` *and* transitions to `Chunked` in the same SQL transaction).
### 5.5 API
`DocumentResponse` (in `document-parser/api/schemas.py`) gains:
```python
class DocumentResponse(BaseModel):
...
lifecycle_state: str = Field(serialization_alias="lifecycleState")
lifecycle_state_at: datetime | None = Field(default=None, serialization_alias="lifecycleStateAt")
```
No new endpoint. The existing `GET /api/documents` and `GET /api/documents/{id}` start returning the two fields. No 4xx removal — purely additive.
`POST /api/documents/{id}/lifecycle` is **not** added in this issue; transitions are driven by pipeline events, not by the user.
### 5.6 Frontend — feature module
Touched: `frontend/src/features/document/`.
- `api.ts` — bump the `Document` type with `lifecycleState: DocumentLifecycleState` and `lifecycleStateAt: string | null`.
- `store.ts` — store retains the new fields as-is.
- No UI work — that is #211 / #215. This issue ships the typed surface only so #211 can render against it.
`shared/types.ts` gains a re-exported `DocumentLifecycleState` union literal type matching the backend enum.
### 5.7 Cross-cutting
- No feature flag. The lifecycle is permanent infrastructure.
- i18n: tooltip strings for the six states are added in `shared/i18n.ts`, keyed `lifecycle.<state>`. Used by #215.
## 6. Alternatives considered
### Alternative A — Reuse `AnalysisJob.status`
- **Summary:** Promote `AnalysisJob.status` to the document's state, removing the separate `Document.lifecycle_state`.
- **Why not:** `AnalysisJob` has a 1:N relationship with `Document` (a doc can be re-analyzed). The status of the *latest* job is not the same thing as the lifecycle of the *document*. It also conflates parse/chunk/ingest into one state machine, which #202#205 explicitly want to separate.
### Alternative B — Computed state, no column
- **Summary:** Derive the state on-the-fly from analysis-job rows + OpenSearch presence.
- **Why not:** Joining + remote calls on every document list query is too expensive for `/docs` with 1k+ rows. A persisted column lets us index it for the filter (#212). The cost of denormalisation is one extra write per pipeline step.
## 7. API & data contract
### Endpoints
| Method | Path | Request | Response | Breaking? |
|--------|------|---------|----------|-----------|
| GET | `/api/documents` | — | `DocumentResponse[]` (now with `lifecycleState`, `lifecycleStateAt`) | No (additive) |
| GET | `/api/documents/{id}` | — | `DocumentResponse` (now with `lifecycleState`, `lifecycleStateAt`) | No (additive) |
### Persistence schema
```sql
ALTER TABLE documents ADD COLUMN lifecycle_state TEXT NOT NULL DEFAULT 'Uploaded';
ALTER TABLE documents ADD COLUMN lifecycle_state_at TEXT;
CREATE INDEX IF NOT EXISTS idx_documents_lifecycle_state ON documents(lifecycle_state);
```
Existing rows default to `Uploaded`; #206 refines them.
### Env vars / config
None.
### Breaking changes
Additive only.
## 8. Risks & mitigations
| Risk | Audit dimension | Likelihood | Impact | How we notice | Mitigation / rollback |
|------|-----------------|------------|--------|---------------|------------------------|
| Drift between `lifecycle_state` and the actual pipeline state (e.g. crash mid-write leaves `Chunked` recorded but no chunks) | DDD | Medium | Medium | Failed `/docs` rendering; tests catch on integration | Wrap the data write + transition in a single SQL transaction; never write the transition before the data |
| Existing migrations break on environments mid-flight | CI/Build | Low | High | Migration test on a snapshot DB | The migration is purely additive (`ADD COLUMN ... DEFAULT`) — safe to apply on any version |
| Confusion between `AnalysisJob.status` and `Document.lifecycle_state` for newcomers | Documentation | High | Low | Reviews ask | A short paragraph in `docs/architecture.md` describing the two concepts side by side |
| `Failed` state without a known reason makes debugging harder | Tests / Documentation | Medium | Medium | Operator complaints | The transition payload carries an optional `reason: str` persisted in a small JSON column or in the analysis-job's `error_message` |
## 9. Testing strategy
### Backend — pytest (`document-parser/tests/`)
- **Unit (`tests/domain/`):**
- `test_lifecycle_transitions.py` — table-driven test of every (from, to) pair: allowed → updates state, disallowed → raises `InvalidLifecycleTransition`.
- `test_lifecycle_event.py` — successful transition returns the right `DocumentLifecycleChanged` event.
- **Persistence (`tests/persistence/`):**
- `test_document_repo_lifecycle.py` — write a doc, read it back, lifecycle fields round-trip; default value is `Uploaded`; index exists.
- **Services / integration:**
- `test_analysis_service_transitions.py` — parse pipeline drives `Uploaded → Parsed`; chunking drives `Parsed → Chunked`; failure → `Failed`.
### Frontend — Vitest
- `features/document/api.test.ts` — DTO parses `lifecycleState` correctly into the typed enum.
- `features/document/store.test.ts` — store exposes `lifecycleState` on the cached document.
### E2E — Karate UI
Out of scope for this issue. E2E coverage lands with #211 (the library page that renders the badge).
### Manual QA
1. `docker compose -f docker-compose.dev.yml up`.
2. Upload a doc → `GET /api/documents/{id}` returns `"lifecycleState": "Uploaded"`.
3. Run analysis → state becomes `"Parsed"` then `"Chunked"`.
4. Force a failure (corrupt PDF) → state becomes `"Failed"`.
## 10. Rollout & observability
### Release branch
`release/0.6.0` (this work).
### Feature flag / staged rollout
None. Lifecycle persistence is always on; UI surfaces are flagged separately (per #210).
### Observability
- Each transition is logged at `INFO` with structured keys: `event=lifecycle_changed doc_id=<id> from=<state> to=<state>`.
- No new Prometheus counter in this issue (added in #211 if useful for the library page).
### Rollback plan
The migration is additive. Reverting the code (without dropping the column) leaves the column populated but unused — safe. If the column itself must go, write a follow-up migration; SQLite supports column drop since 3.35 and we run a recent enough version.
## 11. Open questions
- Should we record the `reason` for `Failed` transitions on the document directly, or rely on the linked `AnalysisJob.error_message`? **Decision for 0.6.0:** rely on `AnalysisJob.error_message`; revisit if multiple non-analysis sources of failure emerge.
- Do we want a typed `DocumentLifecycleEvent` table for audit, or are logs enough? **Decision:** logs only in 0.6.0; #205's `chunk_edits` table is the audit substrate, doc-level events can be added later if needed.
## 12. References
- **Issue:** https://github.com/scub-france/Docling-Studio/issues/202
- **Related issues:** #203 (per-store state), #204 (auto-stale), #205 (audit trail), #206 (migration), #211 (library), #215 (status badges)
- **ADRs:** none planned
- **Project docs:**
- Architecture: `docs/architecture.md`
- Coding standards: `docs/architecture/coding-standards.md`
- Audit master: `docs/audit/master.md`

View file

@ -0,0 +1,329 @@
# 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 `Store` entity in the domain (id, name, kind, config, embedder).
- [ ] Introduce a `DocumentStoreLink` entity tying a document to a store with its own state and metadata.
- [ ] States on the link: `Ingested`, `Stale`, `Failed`. (No `Uploaded`/`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 `Store` row 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``VectorStore` protocol (line 124).
- `document-parser/infra/opensearch_store.py``OpenSearchStore` adapter.
- `document-parser/services/ingestion_service.py``IngestionService.ingest()` and `ensure_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)
- `Store` and `DocumentStoreLink` are domain entities (`domain/models.py`). Pure data + invariants. No ORM concern.
- `StoreRepository` and `DocumentStoreLinkRepository` are ports (`domain/ports.py`).
- aiosqlite adapters live in `persistence/store_repo.py` and `persistence/document_store_link_repo.py`.
- The existing `VectorStore` port stays — it represents the **technology** (OpenSearch). The new `Store` entity represents the **logical store** (a named, configurable target). One `VectorStore` adapter can serve many `Store` entities by namespacing the index name.
### Deployment modes
- Single `OpenSearchStore` adapter handles all stores via per-store `index_name = f"docling-studio-{store.slug}"`. Existing default index gets migrated to `docling-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`:
```python
class StoreKind(StrEnum):
OPENSEARCH = "opensearch"
# future: PINECONE, QDRANT, …
class DocumentStoreLinkState(StrEnum):
INGESTED = "Ingested"
STALE = "Stale"
FAILED = "Failed"
```
`document-parser/domain/models.py`:
```python
@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
```sql
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`:
```sql
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:
```python
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")`:
1. Read chunks from `analysis_jobs.chunks_json`.
2. Compute embeddings.
3. Call adapter `index_chunks(store_slug, ...)`.
4. Upsert link via `DocumentStoreLinkRepository.upsert(doc_id, store_id, state=Ingested, chunkset_hash=..., at=now)`.
5. 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`:
```python
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 optional `storeSlug`; defaults to "default" if not supplied.
- New `frontend/src/features/stores/` — Pinia store, API client, types.
- `frontend/src/features/document/api.ts``Document` type gains `stores: StoreLink[]`.
### 5.7 Cross-cutting
- Feature flag: none.
- i18n: `stores.state.<state>` keys in `shared/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_json` on `analysis_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 `VectorStore` adapters 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 import `infra/opensearch_store.py`.
### Frontend — Vitest
- `features/stores/api.test.ts` — list endpoint round-trip.
- `features/document/store.test.ts``Document.stores` populated from API.
### E2E — Karate UI
Out of scope here. Lands with #211 / #218.
### Manual QA
1. Boot. `GET /api/stores` returns the seeded `default`.
2. Upload a doc, run ingestion → `GET /api/documents/{id}` returns `stores[0].state == "Ingested"`.
3. Inspect SQLite: `document_store_links` has 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_changed` with `doc_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.config` be typed in the domain (via a discriminated union on `kind`)? **Decision for 0.6.0:** keep as opaque `dict`; introduce a typed wrapper when we add a second `kind`.
- 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`

View file

@ -0,0 +1,265 @@
# Design: Auto-detect Stale state via chunk content hash
- **Issue:** #204
- **Title on issue:** [FEATURE] Auto-detect Stale state via chunk content hash
- **Author:** Pier-Jean Malandrino
- **Date:** 2026-04-29
- **Status:** Accepted
- **Target milestone:** 0.6.0 — Doc-centric ingest
- **Impacted layers:** backend: domain · persistence · services · frontend (read-only)
- **Audit dimensions likely touched:** Hexagonal Architecture · DDD · Performance · Tests · Security
- **ADR spawned?:** no
---
## 1. Problem
Without auto-detection, a user who edits a chunk has to remember which stores hold the doc and click "re-ingest" everywhere. They will forget. The next query against the stale store returns the old embedding, and the customer reports "I fixed it yesterday and it's still wrong". Manual flagging is a bug factory.
The detection contract is: **the system, not the user, knows when a stored chunkset diverges from the current draft chunkset.** Implementation = a deterministic hash recorded at push time and compared on read or on chunk write.
## 2. Goals
- [ ] Define a deterministic `chunkset_hash(chunks: list[ChunkResult])` function.
- [ ] Store the hash on each `DocumentStoreLink` at push time (#203 ships the column slot).
- [ ] On any chunk modification, recompute the current chunkset hash and compare against each link; if mismatch, mark the link `Stale` and re-aggregate the document state.
- [ ] On document read (single-doc API), compare on the fly as a safety net so older drift is caught.
- [ ] Unit tests prove: edit a chunk → link state becomes `Stale`; re-push → link state becomes `Ingested` with the new hash.
## 3. Non-goals
- Per-chunk change tracking — that's the audit trail (#205); this issue cares about the *aggregate* hash only.
- Background sweeper job that scans the whole corpus for drift — for 0.6.0, detection is event-driven (on chunk write) + on-read; a sweeper can come later.
- Diff-aware re-ingest at the chunk granularity — that's #223; this issue tells you *whether* a re-ingest is needed, not which chunks to re-embed.
- A user-facing toggle to "force stale" — out of scope.
## 4. Context & constraints
### Existing code surface
- `document-parser/domain/value_objects.py``ChunkResult` (line 93).
- `document-parser/services/analysis_service.py` — chunking pipeline.
- `document-parser/persistence/database.py``analysis_jobs.chunks_json` (the canonical chunkset today).
- `document-parser/services/ingestion_service.py` — push pipeline.
- `document-parser/api/ingestion.py` — push endpoint.
- `document-parser/persistence/document_store_link_repo.py` (created by #203).
### Hexagonal Architecture constraints
- `chunkset_hash` is a **pure domain function** — lives in `domain/hashing.py`. No I/O. No timestamps. Deterministic over the input.
- Detection (compare + transition) is orchestrated in services. Persistence reads the stored hash via the link repo.
- The hash is opaque to API/frontend in 0.6.0; surfaced only as a debug field on `StoreLinkResponse` (#203 added it).
### Hard constraints
- Hash function must be **stable across processes / machines / Python versions** — so SHA-256 (`hashlib`), not Python `hash()`. No salt.
- Must be cheap on a 500-chunk doc — a single linear pass, no JSON re-parse on hot path.
- Result is a hex string, length 64. Stored as `TEXT` in SQLite.
## 5. Proposed design
### 5.1 Domain
`document-parser/domain/hashing.py`:
```python
import hashlib
import json
from collections.abc import Iterable
from .value_objects import ChunkResult
def chunkset_hash(chunks: Iterable[ChunkResult]) -> str:
"""
Deterministic hash over a chunkset.
Hashed inputs (per chunk, in chunkset order):
- text (str)
- source_page (int | None)
- headings (list[str], preserved order)
Excluded:
- bboxes / doc_items (rendering artefacts; do not affect retrieval semantics)
- token_count (derived; unstable across tokenizers)
"""
h = hashlib.sha256()
for chunk in chunks:
payload = {
"t": chunk.text,
"p": chunk.source_page,
"h": list(chunk.headings or []),
}
h.update(b"\x1f")
h.update(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode())
return h.hexdigest()
```
Notes:
- The `\x1f` (Unit Separator) byte between chunks defends against the "join attack" where chunk A's tail and chunk B's head produce the same hash as chunk A+B merged.
- `separators=(",", ":")` is the canonical compact JSON form.
- The exclusion list is intentional and documented inline — changing it bumps every doc to `Stale` once and is a deliberate one-time event (covered in §10 Rollback).
`detect_stale_links(current_hash, links) -> list[DocumentStoreLink]` — pure helper returning the subset of links whose `chunkset_hash != current_hash`.
### 5.2 Persistence
No new schema. The `chunkset_hash` column was added by #203 on `document_store_links`.
A new index supports the on-read safety net:
```sql
CREATE INDEX IF NOT EXISTS idx_dsl_doc_state ON document_store_links(document_id, state);
```
### 5.3 Infra adapters
None. Hashing is in-memory.
### 5.4 Services
Two call sites:
**A. On chunk write (event-driven detection)**
`AnalysisService.persist_chunks(doc_id, chunks)`:
1. Persist chunks (existing path: `analysis_jobs.chunks_json`).
2. Compute `current_hash = chunkset_hash(chunks)`.
3. Read all `DocumentStoreLink` rows for `doc_id`.
4. For each link with `chunkset_hash != current_hash` and `state in (Ingested,)`:
- `link.mark_stale(at=now)` and persist.
5. Re-aggregate the document state (#202).
This runs in the same transaction as the chunk write. The full pass is O(N_links) which is small (a doc rarely lives in more than a handful of stores).
**B. On push completion (set the new hash)**
Already covered by #203's `IngestionService.ingest`. Add the `current_hash` argument when calling `link.mark_ingested(hash_=current_hash, at=now, run_id=...)`.
**C. On read (safety net)**
`DocumentService.find_by_id(id)`:
1. Fetch document + links (existing).
2. Compute `current_hash` (cheap; cached per request).
3. Mark any `Ingested` link with mismatched hash as `Stale` and persist (best-effort, swallowed if write fails — log).
This guards against drift caused by direct DB writes / restored backups / older deploys.
### 5.5 API
No new endpoint. `StoreLinkResponse.chunksetHash` (already added by #203) is now actually populated.
### 5.6 Frontend — feature module
No changes in this issue. #224 (stale indicator) reads the existing field.
### 5.7 Cross-cutting
- Feature flag: none.
- Logs: `INFO event=stale_detected doc_id=<id> store_id=<id> previous_hash=<8> current_hash=<8>` (truncated for privacy / log volume).
- ADR: not required. The choice of SHA-256 + canonical JSON is documented inline in `hashing.py`.
## 6. Alternatives considered
### Alternative A — Per-chunk hashes only (no chunkset hash)
- **Summary:** Skip the aggregate hash; track each chunk's hash and detect "any per-chunk hash drift".
- **Why not:** Per-chunk hashing is needed for #223 (diff-aware re-embed) but not for "is this link stale at all?". A single `chunkset_hash` is one-comparison cheap; per-chunk is N-comparisons. Both will exist after #223 lands; this issue ships the cheap top-level signal.
### Alternative B — Store updated_at instead of hash
- **Summary:** Compare `chunks.updated_at > link.last_push_at`.
- **Why not:** Brittle. Re-running a pipeline on identical input bumps `updated_at` without semantic change. Hash is content-addressed and survives idempotent rewrites.
### Alternative C — MD5 / xxHash
- **Summary:** Use a faster non-cryptographic hash.
- **Why not:** SHA-256 is fast enough on the volumes in scope (`<500 chunks`, microseconds in CPython hashlib via OpenSSL bindings). The cryptographic hash also gives us collision resistance for free. xxHash would require an extra dependency.
## 7. API & data contract
### Endpoints
No additions. Existing `StoreLinkResponse.chunksetHash` becomes populated.
### Persistence schema
```sql
CREATE INDEX IF NOT EXISTS idx_dsl_doc_state ON document_store_links(document_id, state);
```
### Env vars / config
None.
### Breaking changes
None.
## 8. Risks & mitigations
| Risk | Audit dimension | Likelihood | Impact | How we notice | Mitigation / rollback |
|------|-----------------|------------|--------|---------------|------------------------|
| Hash function changes (e.g. someone adds `bboxes` to the input) silently invalidates every link | DDD | Medium | High | Every doc flips to `Stale` post-deploy | Hash function is in a single file with a docstring listing the canonical inputs; CI fixture asserts a fixed hash for a known chunkset. Bumping requires updating the fixture deliberately. |
| Read-side detection updates state under a GET (write-on-read) | Performance / Decoupling | Low | Medium | Slow / unexpected SQL in read paths | Best-effort, swallowed write; only triggered when the API actually serves the doc detail page (already a write-allowed code path). Disabled if a query param `?refresh=false` is set (future). |
| Unicode normalization issues (NFC vs NFD) produce different hashes for "the same" text | Tests | Low | Medium | Drift after a copy-paste from a Mac | Document the policy: text is stored as-is, no normalization; if drift appears, normalize at write time, not at hash time. |
| JSON ordering instability across Python versions | Tests | Low | High | Hash mismatch on different prod nodes | `separators=(",", ":")` + `ensure_ascii=False` + explicit key order in the dict literal. Reviewer checklist mentions this. |
## 9. Testing strategy
### Backend — pytest
- **Unit (domain):**
- `test_chunkset_hash_determinism.py` — same input → same output across multiple invocations.
- `test_chunkset_hash_sensitivity.py` — every input field in the canonical list changes the hash; excluded fields (`bboxes`, `token_count`) do not.
- `test_chunkset_hash_join_attack.py` — separating into different chunks must produce different hash from concatenation.
- **Locked fixture** `test_chunkset_hash_fixture.py` — a hand-built 3-chunk input whose hash is hard-coded; CI fails if anyone changes the function silently.
- **Services:**
- `test_stale_detection_on_edit.py` — edit a chunk → link state becomes `Stale`.
- `test_stale_clears_on_repush.py` — re-push → link state becomes `Ingested` with the new hash.
- `test_stale_safety_net_on_read.py` — direct DB tampering → next read flips state to `Stale`.
### Frontend — Vitest
None new in this issue.
### E2E — Karate UI
Out of scope here; lands with #224.
### Manual QA
1. Push a doc to the default store → `chunksetHash` populated, `state == "Ingested"`.
2. Edit a chunk via API → `state == "Stale"`.
3. Re-push → `state == "Ingested"`, new hash.
## 10. Rollout & observability
### Release branch
`release/0.6.0`.
### Feature flag
None. Detection is always on; cheap; correctness improvement.
### Observability
- Log lines as in §5.7.
- One-time bump scenario: if we ever change the canonical input list, every link will flip to `Stale` once. That is a deliberate decision; the operator must be informed via release notes, and a one-shot reindex job is recommended (out of scope here).
### Rollback plan
The migration is additive (a new index). Reverting the code leaves the existing `chunkset_hash` column populated but unused — harmless. The index can be dropped in a follow-up.
## 11. Open questions
- Should the safety-net read-side check be opt-in via a query param? **Decision:** always-on for 0.6.0; revisit if the cost shows up in profiles.
- Should headings include the *path* (parent → leaf) or just the leaf? **Decision:** the full ordered list as it sits on `ChunkResult.headings`. If the source mutates the list semantics, that is a separate domain concern.
## 12. References
- **Issue:** https://github.com/scub-france/Docling-Studio/issues/204
- **Related issues:** #202 (lifecycle), #203 (per-store state), #205 (audit), #206 (migration), #223 (diff-aware re-ingest), #224 (stale indicator)
- **ADRs:** none planned
- **Project docs:**
- Architecture: `docs/architecture.md`
- Coding standards: `docs/architecture/coding-standards.md`

View file

@ -0,0 +1,372 @@
# Design: Audit trail for chunk edits
- **Issue:** #205
- **Title on issue:** [FEATURE] Audit trail for chunk edits (who, when, before/after)
- **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 · frontend: features/chunking · shared
- **Audit dimensions likely touched:** Hexagonal Architecture · DDD · Tests · Security · Documentation
- **ADR spawned?:** ADR-NNN — "Chunks become a first-class persisted entity"
---
## 1. Problem
Once chunks become editable in 0.6.0 (E5), production teams will need to answer "who changed what, when, and why is the answer suddenly different?". Without an audit trail, a regression caused by a chunk edit is impossible to investigate.
Today the architecture makes this hard because **chunks are not persisted as first-class records**. They live as a JSON blob inside `analysis_jobs.chunks_json` and as derived rows in OpenSearch. There is no chunk identity that survives a re-parse, no row to attach an audit record to, and no way to retrieve "the version of these chunks at the last push".
This issue elevates chunks to a first-class persisted entity (with stable IDs and content versioning), introduces an immutable `chunk_edits` table, and exposes a snapshot API consumed by the visual diff (#221).
## 2. Goals
- [ ] Promote chunks to a first-class persisted entity with stable IDs across edits.
- [ ] Every chunk operation (create / update / delete / merge / split) writes an immutable `chunk_edits` record with: actor, timestamp, action, before-state, after-state, optional reason.
- [ ] API: `GET /api/documents/{id}/chunks/history` returns the edit timeline.
- [ ] API: `GET /api/documents/{id}/chunks?at=<push_id>` returns the chunkset snapshot at a given push.
- [ ] Backwards compatible: existing reads of `analysis_jobs.chunks_json` keep working until #206 finishes the migration.
## 3. Non-goals
- Undo / redo UI — out of scope (the audit trail is the substrate; UI comes later).
- Rollback to an arbitrary past state — not in 0.6.0; use the snapshot API for read-only inspection.
- A diff *view* — that is **#221**.
- Per-chunk content hash — that is part of **#223**, not this issue. (#204's `chunkset_hash` is at the link granularity.)
- Authentication / authorization model rework — `actor` defaults to a hard-coded `"system"` until the auth layer is ready.
## 4. Context & constraints
### Existing code surface
- `document-parser/domain/value_objects.py``ChunkResult` (transient).
- `document-parser/domain/models.py``AnalysisJob.chunks_json` (current chunkset storage).
- `document-parser/services/analysis_service.py` — chunking pipeline.
- `document-parser/persistence/database.py` — schema + migrations.
- `document-parser/api/` — no chunks endpoint today; needs creation.
- `frontend/src/features/chunking/` — Pinia store + API client.
### Hexagonal Architecture constraints
- New domain entities `Chunk` and `ChunkEdit` live in `domain/models.py`.
- `ChunkRepository` and `ChunkEditRepository` are ports (`domain/ports.py`).
- aiosqlite adapters in `persistence/chunk_repo.py` and `persistence/chunk_edit_repo.py`.
- A new `ChunkEditingService` in `services/` orchestrates the operations and writes audit records atomically with the chunk write.
- The existing `analysis_jobs.chunks_json` becomes a **legacy fallback** — read by `ChunkRepository.list_for_doc()` if no rows exist in the new `chunks` table. #206 backfills.
### Hard constraints
- Stable `chunk.id` across edits. A chunk that was split into two creates two new ids; merging two chunks produces a third new id. The "lineage" is recorded in `chunk_edits`. (No "ship the same id" hack.)
- Immutable audit table. Once written, never updated.
- No PII leak in audit records — `actor` is whatever the auth layer hands us; `before`/`after` payloads are the chunk text and metadata only.
- Atomicity: an edit + its audit row are written in the same SQL transaction.
## 5. Proposed design
### 5.1 Domain
`document-parser/domain/models.py`:
```python
@dataclass
class Chunk:
id: str # uuid4 hex
document_id: str
sequence: int # ordering within the doc; gaps allowed
text: str
headings: list[str]
source_page: int | None
bboxes: list[Bbox] # carried for rendering; not part of identity
doc_items: list[str]
token_count: int | None
created_at: datetime
updated_at: datetime
deleted_at: datetime | None # soft delete
class ChunkEditAction(StrEnum):
INSERT = "insert"
UPDATE = "update"
DELETE = "delete"
MERGE = "merge"
SPLIT = "split"
@dataclass(frozen=True)
class ChunkEdit:
id: str
document_id: str
chunk_id: str | None # null on MERGE result row (uses children_ids)
action: ChunkEditAction
actor: str # "system" until auth lands
at: datetime
before: dict | None # JSON snapshot — None for INSERT
after: dict | None # JSON snapshot — None for DELETE
parents: list[str] # for SPLIT result rows: source chunk id; for MERGE: source ids
children: list[str] # inverse links
reason: str | None
```
Domain operations on a chunkset (`domain/chunk_editing.py`):
```python
def insert(chunks: list[Chunk], at_position: int, new_chunk: Chunk) -> list[Chunk]: ...
def update(chunks: list[Chunk], chunk_id: str, *, text: str, headings: list[str]) -> list[Chunk]: ...
def delete(chunks: list[Chunk], chunk_id: str) -> list[Chunk]: ...
def merge(chunks: list[Chunk], chunk_ids: list[str]) -> tuple[list[Chunk], Chunk]:
"""Returns the updated list and the new merged chunk."""
def split(chunks: list[Chunk], chunk_id: str, at_offset: int) -> tuple[list[Chunk], Chunk, Chunk]:
"""Returns the updated list and the two new chunks."""
```
These are pure. The service wraps each call with audit-record generation.
### 5.2 Persistence
```sql
CREATE TABLE IF NOT EXISTS chunks (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
sequence INTEGER NOT NULL,
text TEXT NOT NULL,
headings TEXT NOT NULL DEFAULT '[]', -- JSON
source_page INTEGER,
bboxes TEXT NOT NULL DEFAULT '[]', -- JSON
doc_items TEXT NOT NULL DEFAULT '[]', -- JSON
token_count INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_chunks_doc ON chunks(document_id);
CREATE INDEX IF NOT EXISTS idx_chunks_doc_seq ON chunks(document_id, sequence);
CREATE TABLE IF NOT EXISTS chunk_edits (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
chunk_id TEXT,
action TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT 'system',
at TEXT NOT NULL,
before_json TEXT,
after_json TEXT,
parents_json TEXT NOT NULL DEFAULT '[]',
children_json TEXT NOT NULL DEFAULT '[]',
reason TEXT
);
CREATE INDEX IF NOT EXISTS idx_chunk_edits_doc_at ON chunk_edits(document_id, at);
CREATE INDEX IF NOT EXISTS idx_chunk_edits_chunk ON chunk_edits(chunk_id);
-- Snapshot table — captures the chunkset hash at every successful push.
CREATE TABLE IF NOT EXISTS chunk_pushes (
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,
chunkset_hash TEXT NOT NULL,
chunk_ids TEXT NOT NULL, -- JSON array of chunk ids in order at push time
pushed_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_chunk_pushes_doc_store ON chunk_pushes(document_id, store_id);
```
Note: `chunk_pushes.chunk_ids` materialises the chunkset at push time. Combined with the immutable `chunk_edits` history, we can reconstruct any past chunkset by replay (slower) — but `chunk_ids` is the cheap path.
### 5.3 Infra adapters
None.
### 5.4 Services
`document-parser/services/chunk_editing_service.py`:
```python
class ChunkEditingService:
def __init__(self, chunks: ChunkRepository, edits: ChunkEditRepository, ...): ...
async def insert(self, doc_id, at_position, payload, *, actor, reason=None) -> Chunk: ...
async def update(self, doc_id, chunk_id, payload, *, actor, reason=None) -> Chunk: ...
async def delete(self, doc_id, chunk_id, *, actor, reason=None) -> None: ...
async def merge(self, doc_id, chunk_ids, *, actor, reason=None) -> Chunk: ...
async def split(self, doc_id, chunk_id, at_offset, *, actor, reason=None) -> tuple[Chunk, Chunk]: ...
async def history(self, doc_id) -> list[ChunkEdit]: ...
async def chunkset_at(self, doc_id, push_id) -> list[Chunk]: ...
```
Every mutating method runs in a single SQL transaction:
1. Apply the domain operation.
2. Persist chunk rows (insert / update / soft-delete).
3. Insert `chunk_edits` row with full before/after snapshot.
4. Trigger #204's stale detection on the doc.
`history()` — paginated (default 50, max 500), ordered `at DESC`.
`chunkset_at(push_id)` — read `chunk_pushes.chunk_ids`, return the matching `chunks` rows in that order.
`IngestionService.ingest()` (touched in #203) writes a row to `chunk_pushes` on success.
### 5.5 API
New router `document-parser/api/chunks.py`:
```
GET /api/documents/{id}/chunks List current chunks
GET /api/documents/{id}/chunks?at=<push_id> Snapshot at push
GET /api/documents/{id}/chunks/history Edit timeline (paginated)
POST /api/documents/{id}/chunks Insert chunk
PATCH /api/documents/{id}/chunks/{chunk_id} Update chunk
DELETE /api/documents/{id}/chunks/{chunk_id} Soft-delete chunk
POST /api/documents/{id}/chunks/merge { chunkIds: [...], reason? } → new chunk
POST /api/documents/{id}/chunks/{chunk_id}/split { atOffset, reason? } → two new chunks
```
DTOs (`schemas.py`, camelCase via alias):
```python
class ChunkResponse(BaseModel):
id: str
sequence: int
text: str
headings: list[str]
sourcePage: int | None
tokenCount: int | None
bboxes: list[BboxDto]
docItems: list[str]
updatedAt: datetime
class ChunkEditResponse(BaseModel):
id: str
chunkId: str | None
action: str
actor: str
at: datetime
before: dict | None
after: dict | None
parents: list[str]
children: list[str]
reason: str | None
```
### 5.6 Frontend — feature module
Touched: `frontend/src/features/chunking/`.
- `api.ts` — clients for the seven endpoints above.
- `store.ts` — Pinia store holds `chunks`, `history`, and a draft layer for optimistic updates with rollback on failure.
- `ui/` — primitives only in this issue (`ChunkRow.vue`, `ChunkActionsMenu.vue`); the full editor is built in #219 / #220 / #221 on top.
- `data-e2e` selectors named upfront: `chunk-row`, `chunk-actions-menu`, `chunk-action-merge`, etc.
### 5.7 Cross-cutting
- Feature flag: existing `chunking` flag (in `/api/health`) gates the editing endpoints. When `false`, the endpoints return `403`.
- i18n: `chunks.action.*`, `chunks.reason.placeholder` keys.
- Shared types: `DocumentChunk`, `ChunkEdit` re-exported from `shared/types.ts`.
## 6. Alternatives considered
### Alternative A — Keep chunks in `analysis_jobs.chunks_json`, version the JSON
- **Summary:** Add a `version` column on `analysis_jobs` and a `chunk_edits` table referencing JSON paths inside `chunks_json`.
- **Why not:** No chunk identity → `before`/`after` lookups become regex on JSON. Splits and merges have nowhere to record lineage. Reviewers and customer support cannot navigate the audit. The architectural cost is paid sooner or later — pay it now while the data is small.
### Alternative B — Use OpenSearch as the source of truth for chunks
- **Summary:** Treat the OpenSearch document as the authoritative chunk; record audit metadata there.
- **Why not:** OpenSearch is a derived index. It is the *projection* of the corpus, not its source. Replacing OpenSearch (Pinecone, Qdrant) would lose history. Source of truth belongs in SQLite.
### Alternative C — Event-sourcing only (no current-state table)
- **Summary:** Drop `chunks` table; replay `chunk_edits` to compute current state.
- **Why not:** Replay cost on every read is unacceptable for chunks-editor latency and for #221's diff. Hybrid (current state + immutable history) is the standard pragmatic shape.
## 7. API & data contract
### Endpoints
See §5.5 — eight new endpoints, all behind the existing `chunking` feature flag.
### Persistence schema
See §5.2.
### Env vars / config
| Name | Default | Allowed | Notes |
|------|---------|---------|-------|
| `CHUNK_HISTORY_PAGE_DEFAULT` | `50` | int 1500 | default page size for history |
| `CHUNK_HISTORY_PAGE_MAX` | `500` | int | hard cap |
### Breaking changes
None. Existing chunks in `chunks_json` remain accessible via the legacy fallback in `ChunkRepository.list_for_doc()` until #206 finishes the migration.
## 8. Risks & mitigations
| Risk | Audit dimension | Likelihood | Impact | How we notice | Mitigation / rollback |
|------|-----------------|------------|--------|---------------|------------------------|
| Audit table growth on heavy editing customers | Performance | Medium | Medium | Slow `chunk_edits` queries | Indexed by (doc, at). Pagination on the API. Future: archival job. |
| Race between two parallel edits on the same chunk | DDD | Low | Medium | Lost update | All mutations go through `ChunkEditingService` which serialises via the SQL transaction; client sends `If-Match` with `updatedAt` for optimistic concurrency on `PATCH` / `DELETE` |
| Audit before/after reveals sensitive content if shared | Security | Medium | Medium | Audit export shared accidentally | The audit endpoint is gated by the same auth as chunk edits; export does not include audit by default; admin-only flag in a future release |
| Schema drift: `chunks_json` and `chunks` table diverge during migration window | DDD | Medium | High | Diff between the two on the same doc | #206 is the single source of truth; new edits write to `chunks` only; reads prefer `chunks` and fall back to `chunks_json` |
## 9. Testing strategy
### Backend — pytest
- **Unit (domain):**
- `test_chunk_editing_pure.py` — insert / update / delete / merge / split on in-memory lists; properties verified (lengths, sequences, identity rules).
- **Persistence:**
- `test_chunk_repo.py` — round-trip, soft delete, ordering by sequence.
- `test_chunk_edit_repo.py` — write + read history; immutability (UPDATE on `chunk_edits` is rejected by a CHECK constraint or service-level guard).
- **Services / integration:**
- `test_chunk_editing_service_audit_atomicity.py` — failure mid-write rolls back both chunk and audit.
- `test_chunkset_at_snapshot.py``chunkset_at(push_id)` returns the right snapshot after edits.
- `test_legacy_fallback.py` — read chunks for a doc that has only `chunks_json` (pre-migration).
### Frontend — Vitest
- `features/chunking/api.test.ts` — eight endpoints round-trip.
- `features/chunking/store.test.ts` — optimistic update + rollback on API failure; history pagination.
### E2E — Karate UI
Out of scope here; the editor lands in #219 / #220 with E2E tags `@critical @ui`.
### Manual QA
1. Trigger an INSERT / UPDATE / DELETE / MERGE / SPLIT.
2. `GET /api/documents/{id}/chunks/history` shows the action with `before`/`after`.
3. After a successful push, `GET /api/documents/{id}/chunks?at=<push_id>` returns the chunkset captured at push.
## 10. Rollout & observability
### Release branch
`release/0.6.0`.
### Feature flag
The existing `chunking` flag (in `/api/health`) controls whether the editing endpoints accept writes. Reads (`GET`) are always allowed (consistent with the chunks tab being read-only when the flag is off).
### Observability
- Each mutation logs: `INFO event=chunk_edit doc_id=<id> chunk_id=<id> action=<action> actor=<actor>`.
- Counter (Prometheus, future): `chunk_edits_total{action}`.
### Rollback plan
The migration is additive. Reverting the code leaves the new tables but no writers. Reads from the chunks tab fall back to the legacy `chunks_json`. Editing endpoints disappear (404). If a clean rollback is needed, drop the three new tables.
## 11. Open questions
- Should `actor` carry a structured object (id, name, role) or stay as a free-form string? **Decision for 0.6.0:** free-form string (`"system"` today; whatever auth provides later). Structured upgrade is non-breaking thanks to JSON-friendly columns.
- Should we generate a *human-readable* diff in the audit row (line-level), or only store before/after JSON? **Decision:** store JSON; render the diff client-side in #221.
## 12. References
- **Issue:** https://github.com/scub-france/Docling-Studio/issues/205
- **Related issues:** #202 (lifecycle), #203 (per-store), #204 (auto-stale), #206 (migration), #219 (editor view), #220 (edit actions), #221 (visual diff), #222 (push)
- **ADRs:** ADR — "Chunks become a first-class persisted entity"
- **Project docs:**
- Architecture: `docs/architecture.md`
- Coding standards: `docs/architecture/coding-standards.md`
- ADR guide: `docs/architecture/adr-guide.md`

View file

@ -0,0 +1,265 @@
# Design: Migrate existing documents to the new lifecycle state model
- **Issue:** #206
- **Title on issue:** [CHORE] Migrate existing documents to the new lifecycle state model
- **Author:** Pier-Jean Malandrino
- **Date:** 2026-04-29
- **Status:** Accepted
- **Target milestone:** 0.6.0 — Doc-centric ingest
- **Impacted layers:** backend: persistence · services · infra (CLI) · frontend (none)
- **Audit dimensions likely touched:** CI/Build · Tests · Documentation · Security
- **ADR spawned?:** no
---
## 1. Problem
#202, #203, and #205 introduce four new tables / columns: `documents.lifecycle_state`, `stores`, `document_store_links`, `chunks`, `chunk_edits`, `chunk_pushes`. Existing tenants already have documents, analysis jobs, and chunks living in `analysis_jobs.chunks_json` plus rows in OpenSearch. After deploy, these documents must appear in `/docs` with sensible state — otherwise the page looks broken (every doc shows `Uploaded` or unknown).
This issue ships an idempotent migration that backfills:
- `documents.lifecycle_state` and `lifecycle_state_at`.
- One `stores` row (the `default` seeded by #203, no-op if already present).
- One `document_store_links` row per document already represented in OpenSearch under the legacy index.
- `chunks` rows materialized from existing `analysis_jobs.chunks_json`.
- `chunk_pushes` rows reconstructed for documents currently indexed.
It also reindexes from `docling-studio-chunks` to `docling-studio-default` if needed, so the new store-aware code can read them.
## 2. Goals
- [ ] Idempotent CLI command `python -m document_parser.tools.migrate_06` (re-runs safely; second run is a no-op).
- [ ] `--dry-run` flag prints what would change without writing.
- [ ] Inference rules below produce a sensible state for every existing document.
- [ ] Per-tenant counts logged at the end (parsed=N, chunked=N, ingested=N, failed=N).
- [ ] Documented in `docs/runbooks/release-0.6.0-migration.md`.
- [ ] Tested on a snapshot DB representing the three relevant pre-states (no analysis, completed analysis, indexed in OpenSearch).
## 3. Non-goals
- Re-running ingestion. The migration only touches metadata; it does not re-embed.
- A web UI for migration progress — operator-only, run via CLI.
- Migrating tenants on environments that have already adopted parts of the new schema (e.g. a partial pre-release deploy) — out of scope; an emergency hotfix path is documented separately.
- Moving to a new vector backend — out of scope.
## 4. Context & constraints
### Existing code surface
- `document-parser/persistence/database.py``_run_migrations()` runs schema DDL on app start.
- `document-parser/persistence/document_repo.py`, `analysis_repo.py` — read paths.
- `document-parser/infra/opensearch_store.py` — read access to existing OpenSearch index.
- New repos from #203 / #205: `store_repo.py`, `document_store_link_repo.py`, `chunk_repo.py`, `chunk_edit_repo.py`.
### Hexagonal Architecture constraints
- The migration is a **CLI script** in `document-parser/tools/migrate_06.py`. It uses repositories only — never raw SQL outside of repo modules. This keeps the upgrade path testable like any service.
- The reindex helper (`docling-studio-chunks → docling-studio-default`) lives in `infra/opensearch_store.py` as a method, called by the script.
### Hard constraints
- Idempotent: re-running on an already-migrated DB makes zero writes.
- Resumable: a crash mid-run can be restarted; checkpoints stored in a tiny `migration_progress` table (rows: `(name, completed_at)`).
- No assumption about uptime: the script must work on a quiesced DB OR a live one (transactions short, single-row scope).
- Read-only on OpenSearch by default; write happens only when the operator explicitly passes `--reindex-default-store`.
### Deployment modes
Run once per environment (`local`, `staging`, `prod`). HF Space deployments — same script, run by the deploy step before the new image starts serving traffic.
## 5. Proposed design
### 5.1 Domain
No new domain code. The migration is a coordinator over existing repos.
### 5.2 Persistence
The schema additions ship in #202 / #203 / #205 migrations (already applied at app start). This issue contributes:
```sql
CREATE TABLE IF NOT EXISTS migration_progress (
name TEXT PRIMARY KEY,
completed_at TEXT NOT NULL
);
```
This is the only new table. It records each migration step's completion to enable resumability.
### 5.3 Infra adapters
`OpenSearchStore.copy_legacy_to_default(*, dry_run: bool) -> int`
1. If `docling-studio-chunks` exists and `docling-studio-default` does not, create the new index with the same mapping.
2. Reindex via `_reindex_op` (OpenSearch reindex API) from legacy → default.
3. Add a read-only alias `docling-studio-chunks → docling-studio-default`.
4. Returns the number of documents copied.
### 5.4 Services
`document-parser/services/migration_06_service.py` — orchestrator with one method per step. Each step is bracketed by:
```python
if not progress.is_done(step_name):
do_step(...)
progress.mark_done(step_name)
```
Steps:
1. **`seed_default_store`** — `INSERT OR IGNORE INTO stores (...) VALUES ('default', ...)`. (Already in #203's migration; this step is a guard for older deploys that skipped the seed.)
2. **`backfill_document_lifecycle_state`** — for each document, infer state:
- Has at least one `analysis_jobs` row with `status=COMPLETED` and a non-null `chunks_json``Chunked` (refined below).
- Has at least one row indexed in OpenSearch (legacy or default index) → `Ingested`.
- Has only `analysis_jobs` rows with `status=COMPLETED` and no `chunks_json``Parsed`.
- Has only `analysis_jobs` rows with `status=FAILED``Failed`.
- Else → `Uploaded`.
3. **`materialize_chunks_from_chunks_json`** — for each `analysis_jobs` row with non-null `chunks_json`, parse and insert rows into `chunks` table. Stable id derivation: `f"chunk-{document_id}-{sequence:05d}-{sha256(text)[:8]}"` so re-runs are deterministic.
4. **`backfill_links_from_opensearch`** — for each document, query OpenSearch (default index, then legacy as fallback) for the count of indexed chunks. If non-zero:
- Insert `document_store_links` row (state = `Ingested`).
- Compute the chunkset hash via #204's function over the materialized `chunks`.
- Set `link.chunkset_hash`.
- Insert a `chunk_pushes` row with the materialized chunk ids (best-effort: ordered by sequence).
5. **`reaggregate_document_lifecycle`** — recompute the doc state by combining the link states (#203's aggregation rule) and update `documents.lifecycle_state` accordingly. This may upgrade or downgrade the value set in step 2 (e.g. `Chunked → Ingested`).
6. **`copy_legacy_index`** *(optional, behind `--reindex-default-store`)* — call `OpenSearchStore.copy_legacy_to_default()`.
Each step is wrapped in its own transaction; partial failure leaves earlier steps committed.
### 5.5 API
None. CLI only.
### 5.6 Frontend
None.
### 5.7 Cross-cutting
- Logging: structured at `INFO` per step, at `ERROR` per failed item with `doc_id`. End summary printed to stdout in a fixed table.
- Configuration via CLI flags only (no env vars introduced for this script):
```
python -m document_parser.tools.migrate_06 \
[--dry-run] \
[--reindex-default-store] \
[--limit N] \
[--only-step <step_name>]
```
- Documentation: `docs/runbooks/release-0.6.0-migration.md` describes the operator flow (backup → run dry-run → run real → validate via `/api/documents`).
## 6. Alternatives considered
### Alternative A — Schema migration applies everything at app boot
- **Summary:** Embed inference logic in `_run_migrations()` so the app starts and self-heals.
- **Why not:** Migration is observable and debuggable as a CLI; baking it into boot time risks slow startup and silent failures. Operators want to run it during a maintenance window with a dry-run first.
### Alternative B — Migrate on demand (lazy)
- **Summary:** Add a "needs migration" check at runtime, materialize chunks for a doc only on first read.
- **Why not:** Surfaces the partial state in the API (`stores: []` for unmigrated docs even if they are indexed). The library page (#211) becomes a half-true representation of reality. Eager migration is simpler.
### Alternative C — Run only the schema; let users re-ingest manually
- **Summary:** Skip backfill; users re-trigger ingestion from the UI as needed.
- **Why not:** A tenant with 10k docs cannot click 10k re-ingest buttons. The killer flow promises "your existing corpus already has state".
## 7. API & data contract
No API changes.
### Persistence schema
See §5.2 (one new table: `migration_progress`).
### CLI
```
python -m document_parser.tools.migrate_06 [flags]
```
All flags are documented in `--help`.
### Breaking changes
None.
## 8. Risks & mitigations
| Risk | Audit dimension | Likelihood | Impact | How we notice | Mitigation / rollback |
|------|-----------------|------------|--------|---------------|------------------------|
| Migration crashes mid-run | CI/Build | Medium | Medium | Error logs | Resumable via `migration_progress`; re-run picks up where it left off |
| Wrong inference for a doc edge case (e.g. multiple stores already) | DDD | Low | Medium | Operator validation (sample 10 docs by hand) | Dry-run lists changes; operator can `--only-step` to redo a single step |
| OpenSearch reindex copies bad data | Decoupling | Low | High | Diff in document counts | Reindex is opt-in (`--reindex-default-store`); operator runs it deliberately; alias keeps legacy reads working |
| Hash mismatch after migration (chunks materialized differ from what was indexed) | DDD | Medium | Medium | Newly-migrated link shows `Stale` immediately | Acceptable behaviour: it correctly tells the user "your indexed chunks may not match the current source"; operator can choose to re-ingest or leave as-is |
| Missing analysis-job rows for a doc | Tests | Low | Low | Doc shows `Uploaded` despite being indexed | Inference falls back to OpenSearch presence; if both empty, `Uploaded` is correct |
## 9. Testing strategy
### Backend — pytest
- **Unit (services):** `test_migration_inference.py` — table-driven: every `(analysis_state, has_chunks_json, indexed)` tuple → expected `lifecycle_state`.
- **Integration:** `test_migration_idempotency.py` — run twice → second run zero writes; mid-run abort + restart → final state matches one-shot run.
- **Persistence:** `test_chunks_materialization.py``chunks_json` → rows; ids deterministic (re-running produces same ids).
### Snapshot fixture
`document-parser/tests/fixtures/db_pre_06.sqlite` — handcrafted SQLite DB with three documents (uploaded only, completed analysis, indexed in OpenSearch via fake adapter). Migration runs against it and the resulting state is asserted.
### Manual QA
1. Snapshot prod DB.
2. Run `--dry-run` on the snapshot → review the printed plan.
3. Run for real on the snapshot → validate via `/api/documents` that every doc has a sensible `lifecycleState` and (where applicable) `stores`.
4. Run the same against prod during the maintenance window.
### Performance
The script is O(N_docs + N_chunks). For 10k docs / 500k chunks: target < 5 minutes on a single-node SQLite. No parallelism needed in 0.6.0.
## 10. Rollout & observability
### Release branch
`release/0.6.0`. The migration ships in the same release as #202 / #203 / #204 / #205 — operators run it after deploying the new code and before sending traffic.
### Feature flag
None.
### Observability
- Stdout summary table at the end:
```
step wrote skipped
seed_default_store 0 1
backfill_document_lifecycle_state 87 0
materialize_chunks_from_chunks_json 14502 0
backfill_links_from_opensearch 73 14
reaggregate_document_lifecycle 73 0
copy_legacy_index — —
total 14735 15
```
- Logs: per-step start / finish; per-error row.
### Rollback plan
- `migration_progress` rows can be deleted to force re-run a specific step.
- The new tables can be truncated or dropped (data is recoverable from `analysis_jobs.chunks_json` and OpenSearch).
- Reverting application code: the new tables stay populated but unused; safe.
## 11. Open questions
- Should the script open a *long* SQLite transaction or many small ones? **Decision:** many small (per-doc), to keep the DB writeable for the live app if the operator chooses to run the script while traffic is on.
- Hash mismatch on freshly-migrated docs (chunks materialized may differ from what was indexed) — should we *force-mark* them `Stale`, or trust the hash compare to do it implicitly? **Decision:** trust the compare. The result is identical and the path is uniform.
## 12. References
- **Issue:** https://github.com/scub-france/Docling-Studio/issues/206
- **Related issues:** #202 (lifecycle), #203 (per-store), #204 (hash), #205 (audit + chunks table)
- **ADRs:** none planned
- **Project docs:**
- Architecture: `docs/architecture.md`
- Coding standards: `docs/architecture/coding-standards.md`
- Operations playbooks: `docs/operations/`

View file

@ -34,6 +34,8 @@ def _to_response(doc) -> DocumentResponse:
file_size=doc.file_size,
page_count=doc.page_count,
created_at=str(doc.created_at),
lifecycle_state=doc.lifecycle_state.value,
lifecycle_state_at=(str(doc.lifecycle_state_at) if doc.lifecycle_state_at else None),
)

View file

@ -56,6 +56,11 @@ class DocumentResponse(_CamelModel):
file_size: int | None = None
page_count: int | None = None
created_at: str | datetime
# 0.6.0 — Document lifecycle state machine (#202). The lifecycle
# describes the document as a whole; `status` above is kept for
# backwards compat and currently still maps to `DOCUMENT_STATUS_UPLOADED`.
lifecycle_state: str = "Uploaded"
lifecycle_state_at: str | datetime | None = None
class AnalysisResponse(_CamelModel):

View file

@ -0,0 +1,35 @@
"""Domain events — frozen records that document state transitions.
Events are produced by domain operations (typically returned from a
mutation method on an aggregate). They are pure data no event bus is
wired in 0.6.0; services can choose to log, persist, or publish them
later. Keeping them here keeps the domain layer free of any event-bus
infrastructure.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datetime import datetime
from domain.value_objects import DocumentLifecycleState
@dataclass(frozen=True)
class DocumentLifecycleChanged:
"""A Document lifecycle transition occurred.
Attributes:
document_id: id of the document that transitioned.
previous: state the document was in before the transition.
current: state the document is in after the transition.
at: timestamp of the transition (UTC).
"""
document_id: str
previous: DocumentLifecycleState
current: DocumentLifecycleState
at: datetime

View file

@ -0,0 +1,37 @@
"""Domain-level exceptions.
Exceptions defined in this module are raised by domain operations and value
objects when an invariant is violated. They have no infrastructure
dependencies and are safe to import from any layer.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from domain.value_objects import DocumentLifecycleState
class DomainError(Exception):
"""Base class for domain-level errors. Catch this when wiring API
layers if you want a single hook for any invariant violation."""
class InvalidLifecycleTransitionError(DomainError):
"""Raised when a Document.transition_to() call asks for a (source,
target) pair that is not in the allowed transition table.
Carries `source` and `target` so callers can produce a useful error
message without re-discovering them.
"""
def __init__(
self,
*,
source: DocumentLifecycleState,
target: DocumentLifecycleState,
) -> None:
super().__init__(f"Invalid document lifecycle transition: {source.value} -> {target.value}")
self.source = source
self.target = target

View file

@ -0,0 +1,83 @@
"""Document lifecycle state machine — pure domain logic.
Defines the canonical state model for a Document in Docling Studio:
Uploaded Parsed Chunked Ingested (Stale|Chunked) Ingested
Stale is set by the auto-detect logic (#204) — never reached by a manual
action. Failed is reachable from any state and represents a pipeline error.
This module contains:
- The transition table (which (from to) pairs are allowed).
- `is_allowed_transition`: the pure check.
- `assert_transition`: raises `InvalidLifecycleTransitionError` when not allowed.
The dataclass that carries the state lives in `domain.models.Document`. The
domain event emitted on transition lives in `domain.events`.
"""
from __future__ import annotations
from domain.exceptions import InvalidLifecycleTransitionError
from domain.value_objects import DocumentLifecycleState
# Allowed transitions: from → set of allowed targets.
# Failed is always reachable as a terminal-ish state (see notes below).
# Self-loops are allowed only where they make pipeline sense (e.g. re-chunk).
_TRANSITIONS: dict[DocumentLifecycleState, frozenset[DocumentLifecycleState]] = {
DocumentLifecycleState.UPLOADED: frozenset(
{
DocumentLifecycleState.PARSED,
DocumentLifecycleState.CHUNKED, # parse + chunk in one pipeline call
DocumentLifecycleState.FAILED,
}
),
DocumentLifecycleState.PARSED: frozenset(
{
DocumentLifecycleState.PARSED, # idempotent re-parse
DocumentLifecycleState.CHUNKED,
DocumentLifecycleState.FAILED,
}
),
DocumentLifecycleState.CHUNKED: frozenset(
{
DocumentLifecycleState.CHUNKED, # re-chunk
DocumentLifecycleState.INGESTED,
DocumentLifecycleState.FAILED,
}
),
DocumentLifecycleState.INGESTED: frozenset(
{
DocumentLifecycleState.STALE,
DocumentLifecycleState.CHUNKED, # re-chunk after ingest
DocumentLifecycleState.INGESTED, # re-push (idempotent)
DocumentLifecycleState.FAILED,
}
),
DocumentLifecycleState.STALE: frozenset(
{
DocumentLifecycleState.INGESTED,
DocumentLifecycleState.CHUNKED,
DocumentLifecycleState.FAILED,
}
),
# Failed is a recoverable state — the operator (or a retry) can move
# back to a non-terminal state by re-running the relevant pipeline step.
DocumentLifecycleState.FAILED: frozenset(
{
DocumentLifecycleState.UPLOADED,
DocumentLifecycleState.PARSED,
DocumentLifecycleState.CHUNKED,
}
),
}
def is_allowed_transition(source: DocumentLifecycleState, target: DocumentLifecycleState) -> bool:
"""Return True iff transitioning from `source` to `target` is allowed."""
return target in _TRANSITIONS.get(source, frozenset())
def assert_transition(source: DocumentLifecycleState, target: DocumentLifecycleState) -> None:
"""Raise `InvalidLifecycleTransitionError` if the transition is not allowed."""
if not is_allowed_transition(source, target):
raise InvalidLifecycleTransitionError(source=source, target=target)

View file

@ -7,6 +7,10 @@ import uuid
from dataclasses import dataclass, field
from datetime import UTC, datetime
from domain.events import DocumentLifecycleChanged
from domain.lifecycle import assert_transition
from domain.value_objects import DocumentLifecycleState
class AnalysisStatus(enum.StrEnum):
PENDING = "PENDING"
@ -32,6 +36,36 @@ class Document:
page_count: int | None = None
storage_path: str = ""
created_at: datetime = field(default_factory=_utcnow)
lifecycle_state: DocumentLifecycleState = DocumentLifecycleState.UPLOADED
lifecycle_state_at: datetime | None = None
def transition_to(
self,
target: DocumentLifecycleState,
*,
now: datetime | None = None,
) -> DocumentLifecycleChanged:
"""Move the document to `target`, validating the transition.
Returns the corresponding `DocumentLifecycleChanged` event so the
caller (typically a service) can log / persist / publish it. The
event is pure data no event bus is wired in 0.6.0.
Raises:
InvalidLifecycleTransitionError: if (current target) is not in
the allowed transition table.
"""
previous = self.lifecycle_state
assert_transition(previous, target)
at = now or _utcnow()
self.lifecycle_state = target
self.lifecycle_state_at = at
return DocumentLifecycleChanged(
document_id=self.id,
previous=previous,
current=target,
at=at,
)
@dataclass

View file

@ -9,12 +9,15 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Protocol, runtime_checkable
if TYPE_CHECKING:
from datetime import datetime
from domain.models import AnalysisJob, Document
from domain.value_objects import (
ChunkingOptions,
ChunkResult,
ConversionOptions,
ConversionResult,
DocumentLifecycleState,
LLMProviderType,
ReasoningResult,
)
@ -85,6 +88,13 @@ class DocumentRepository(Protocol):
async def update_page_count(self, doc_id: str, page_count: int) -> None: ...
async def update_lifecycle(
self,
doc_id: str,
state: DocumentLifecycleState,
at: datetime,
) -> None: ...
async def delete(self, doc_id: str) -> bool: ...

View file

@ -14,6 +14,31 @@ DEFAULT_PAGE_WIDTH: float = 612.0
DEFAULT_PAGE_HEIGHT: float = 792.0
class DocumentLifecycleState(StrEnum):
"""Canonical lifecycle of a Document in Docling Studio.
Distinct from `AnalysisStatus` (which describes a single conversion
attempt). The lifecycle describes the document as a whole:
Uploaded raw file persisted, no parse yet
Parsed conversion produced a document tree
Chunked chunker produced a draft chunkset (pre-store)
Ingested chunkset has been embedded into at least one store
Stale a chunkset was edited after a successful push and the
corresponding store no longer matches (#204)
Failed a pipeline step failed; recoverable by retry
Allowed transitions live in `domain.lifecycle._TRANSITIONS`.
"""
UPLOADED = "Uploaded"
PARSED = "Parsed"
CHUNKED = "Chunked"
INGESTED = "Ingested"
STALE = "Stale"
FAILED = "Failed"
@dataclass(frozen=True)
class PageElement:
type: str

View file

@ -45,22 +45,59 @@ CREATE INDEX IF NOT EXISTS idx_documents_created_at ON documents(created_at);
"""
_MIGRATIONS = [
("document_json", "ALTER TABLE analysis_jobs ADD COLUMN document_json TEXT"),
("chunks_json", "ALTER TABLE analysis_jobs ADD COLUMN chunks_json TEXT"),
("progress_current", "ALTER TABLE analysis_jobs ADD COLUMN progress_current INTEGER"),
("progress_total", "ALTER TABLE analysis_jobs ADD COLUMN progress_total INTEGER"),
# Column migrations: (table, column_name, ddl). Idempotent — applied only
# when the column is missing from the live schema. Order matters when a
# later migration depends on an earlier one (none today).
_COLUMN_MIGRATIONS: list[tuple[str, str, str]] = [
("analysis_jobs", "document_json", "ALTER TABLE analysis_jobs ADD COLUMN document_json TEXT"),
("analysis_jobs", "chunks_json", "ALTER TABLE analysis_jobs ADD COLUMN chunks_json TEXT"),
(
"analysis_jobs",
"progress_current",
"ALTER TABLE analysis_jobs ADD COLUMN progress_current INTEGER",
),
(
"analysis_jobs",
"progress_total",
"ALTER TABLE analysis_jobs ADD COLUMN progress_total INTEGER",
),
# 0.6.0 — Document lifecycle state machine (#202).
(
"documents",
"lifecycle_state",
"ALTER TABLE documents ADD COLUMN lifecycle_state TEXT NOT NULL DEFAULT 'Uploaded'",
),
(
"documents",
"lifecycle_state_at",
"ALTER TABLE documents ADD COLUMN lifecycle_state_at TEXT",
),
]
# DDL statements run after column migrations — typically CREATE INDEX
# IF NOT EXISTS for indexes on freshly-added columns.
_POST_MIGRATION_DDL: list[str] = [
"CREATE INDEX IF NOT EXISTS idx_documents_lifecycle_state ON documents(lifecycle_state)",
]
async def _run_migrations(db: aiosqlite.Connection) -> None:
"""Add columns that may be missing in older databases."""
cursor = await db.execute("PRAGMA table_info(analysis_jobs)")
existing = {row[1] for row in await cursor.fetchall()}
for col_name, ddl in _MIGRATIONS:
if col_name not in existing:
"""Apply additive column migrations, then any post-migration DDL.
Existing columns are detected via PRAGMA `table_info`, so re-running
the migration on an already-up-to-date DB is a no-op.
"""
columns_by_table: dict[str, set[str]] = {}
for table, col_name, ddl in _COLUMN_MIGRATIONS:
if table not in columns_by_table:
cursor = await db.execute(f"PRAGMA table_info({table})")
columns_by_table[table] = {row[1] for row in await cursor.fetchall()}
if col_name not in columns_by_table[table]:
await db.execute(ddl)
logger.info("Migration: added column %s to analysis_jobs", col_name)
columns_by_table[table].add(col_name)
logger.info("Migration: added column %s to %s", col_name, table)
for ddl in _POST_MIGRATION_DDL:
await db.execute(ddl)
await db.commit()

View file

@ -5,15 +5,34 @@ from __future__ import annotations
from datetime import UTC, datetime
from domain.models import Document
from domain.value_objects import DocumentLifecycleState
from persistence.database import get_connection
def _parse_iso(value: str | None) -> datetime | None:
if value is None or value == "":
return None
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed
def _row_to_document(row) -> Document:
created = row["created_at"]
if isinstance(created, str):
created = datetime.fromisoformat(created)
if created.tzinfo is None:
created = created.replace(tzinfo=UTC)
# `_run_migrations` guarantees both lifecycle columns exist by the time
# any read happens, so direct access is safe.
lifecycle_value = row["lifecycle_state"]
lifecycle_state = (
DocumentLifecycleState(lifecycle_value)
if lifecycle_value
else DocumentLifecycleState.UPLOADED
)
lifecycle_state_at = _parse_iso(row["lifecycle_state_at"])
return Document(
id=row["id"],
filename=row["filename"],
@ -22,6 +41,8 @@ def _row_to_document(row) -> Document:
page_count=row["page_count"],
storage_path=row["storage_path"],
created_at=created,
lifecycle_state=lifecycle_state,
lifecycle_state_at=lifecycle_state_at,
)
@ -32,8 +53,10 @@ class SqliteDocumentRepository:
"""Persist a new document record."""
async with get_connection() as db:
await db.execute(
"""INSERT INTO documents (id, filename, content_type, file_size, page_count, storage_path, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
"""INSERT INTO documents (
id, filename, content_type, file_size, page_count,
storage_path, created_at, lifecycle_state, lifecycle_state_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
doc.id,
doc.filename,
@ -42,6 +65,8 @@ class SqliteDocumentRepository:
doc.page_count,
doc.storage_path,
str(doc.created_at),
doc.lifecycle_state.value,
str(doc.lifecycle_state_at) if doc.lifecycle_state_at else None,
),
)
await db.commit()
@ -72,6 +97,25 @@ class SqliteDocumentRepository:
)
await db.commit()
async def update_lifecycle(
self,
doc_id: str,
state: DocumentLifecycleState,
at: datetime,
) -> None:
"""Persist a lifecycle state transition for a document.
Callers must have already validated the transition via
`Document.transition_to()` this method does not check the
transition graph; it just writes.
"""
async with get_connection() as db:
await db.execute(
"UPDATE documents SET lifecycle_state = ?, lifecycle_state_at = ? WHERE id = ?",
(state.value, str(at), doc_id),
)
await db.commit()
async def delete(self, doc_id: str) -> bool:
"""Delete a document by ID. Returns True if a row was removed."""
async with get_connection() as db:

View file

@ -16,6 +16,7 @@ from typing import TYPE_CHECKING
import pypdfium2 as pdfium
from domain.exceptions import InvalidLifecycleTransitionError
from domain.models import AnalysisJob, AnalysisStatus
from domain.services import classify_error, merge_results
from domain.value_objects import (
@ -23,6 +24,7 @@ from domain.value_objects import (
ChunkResult,
ConversionOptions,
ConversionResult,
DocumentLifecycleState,
)
if TYPE_CHECKING:
@ -162,6 +164,10 @@ class AnalysisService:
chunks_json = json.dumps([_chunk_to_dict(c) for c in chunks])
await self._analysis_repo.update_chunks(job_id, chunks_json)
# Re-chunk drives the document into Chunked (idempotent if already
# Chunked; #204 will mark per-store links Stale separately).
await self._transition_document(job.document_id, DocumentLifecycleState.CHUNKED)
return chunks
async def update_chunk_text(self, job_id: str, chunk_index: int, text: str) -> list[dict]:
@ -292,11 +298,47 @@ class AnalysisService:
if job:
job.mark_failed(error)
await self._analysis_repo.update_status(job)
await self._transition_document(job.document_id, DocumentLifecycleState.FAILED)
except OSError:
logger.exception("Database I/O error marking job %s as failed", job_id)
except Exception:
logger.exception("Unexpected error marking job %s as failed", job_id)
async def _transition_document(
self,
document_id: str,
target: DocumentLifecycleState,
) -> None:
"""Drive a Document lifecycle transition (#202).
Idempotent on the target if the document is already in the
requested state, no write happens. Invalid transitions are
logged at WARNING and swallowed so a lifecycle hiccup does not
crash an otherwise-successful pipeline run.
"""
doc = await self._document_repo.find_by_id(document_id)
if doc is None:
return
if doc.lifecycle_state == target:
return
try:
event = doc.transition_to(target)
except InvalidLifecycleTransitionError:
logger.warning(
"Skipped invalid lifecycle transition for doc %s: %s -> %s",
document_id,
doc.lifecycle_state.value,
target.value,
)
return
await self._document_repo.update_lifecycle(document_id, event.current, event.at)
logger.info(
"lifecycle_changed doc_id=%s from=%s to=%s",
document_id,
event.previous.value,
event.current.value,
)
async def _run_analysis(
self,
job_id: str,
@ -379,6 +421,15 @@ class AnalysisService:
if result.page_count:
await self._document_repo.update_page_count(job.document_id, result.page_count)
# Drive the document lifecycle (#202): chunks present → Chunked,
# otherwise → Parsed.
target_state = (
DocumentLifecycleState.CHUNKED
if chunks_json is not None
else DocumentLifecycleState.PARSED
)
await self._transition_document(job.document_id, target_state)
await self._write_tree_to_neo4j(job, result.document_json)
logger.info("Analysis completed: %s (%d pages)", job_id, result.page_count)

View file

@ -10,7 +10,7 @@ import pytest
from fastapi.testclient import TestClient
from api.schemas import ChunkBboxResponse, ChunkingOptionsRequest, ChunkResponse, RechunkRequest
from domain.models import AnalysisJob, AnalysisStatus
from domain.models import AnalysisJob, AnalysisStatus, Document
from domain.value_objects import ChunkBbox, ChunkingOptions, ChunkResult
from main import app
@ -529,6 +529,13 @@ class TestRemoteChunkingPath:
)
analysis_repo.find_by_id = AsyncMock(return_value=job)
analysis_repo.update_chunks = AsyncMock(return_value=True)
# rechunk() now drives a Document lifecycle transition (#202), so
# the document_repo must return a real Document and accept the
# update_lifecycle write.
document_repo.find_by_id = AsyncMock(
return_value=Document(id="d1", filename="test.pdf", storage_path="/tmp/test.pdf")
)
document_repo.update_lifecycle = AsyncMock()
chunks = await service.rechunk(
"j-remote",

View file

@ -0,0 +1,129 @@
"""Tests for the Document lifecycle state machine (#202)."""
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from domain.events import DocumentLifecycleChanged
from domain.exceptions import InvalidLifecycleTransitionError
from domain.lifecycle import assert_transition, is_allowed_transition
from domain.models import Document
from domain.value_objects import DocumentLifecycleState
ALLOWED_TRANSITIONS = [
# Uploaded
(DocumentLifecycleState.UPLOADED, DocumentLifecycleState.PARSED),
(DocumentLifecycleState.UPLOADED, DocumentLifecycleState.CHUNKED),
(DocumentLifecycleState.UPLOADED, DocumentLifecycleState.FAILED),
# Parsed
(DocumentLifecycleState.PARSED, DocumentLifecycleState.PARSED),
(DocumentLifecycleState.PARSED, DocumentLifecycleState.CHUNKED),
(DocumentLifecycleState.PARSED, DocumentLifecycleState.FAILED),
# Chunked
(DocumentLifecycleState.CHUNKED, DocumentLifecycleState.CHUNKED),
(DocumentLifecycleState.CHUNKED, DocumentLifecycleState.INGESTED),
(DocumentLifecycleState.CHUNKED, DocumentLifecycleState.FAILED),
# Ingested
(DocumentLifecycleState.INGESTED, DocumentLifecycleState.STALE),
(DocumentLifecycleState.INGESTED, DocumentLifecycleState.CHUNKED),
(DocumentLifecycleState.INGESTED, DocumentLifecycleState.INGESTED),
(DocumentLifecycleState.INGESTED, DocumentLifecycleState.FAILED),
# Stale
(DocumentLifecycleState.STALE, DocumentLifecycleState.INGESTED),
(DocumentLifecycleState.STALE, DocumentLifecycleState.CHUNKED),
(DocumentLifecycleState.STALE, DocumentLifecycleState.FAILED),
# Failed (recoverable)
(DocumentLifecycleState.FAILED, DocumentLifecycleState.UPLOADED),
(DocumentLifecycleState.FAILED, DocumentLifecycleState.PARSED),
(DocumentLifecycleState.FAILED, DocumentLifecycleState.CHUNKED),
]
@pytest.mark.parametrize(("source", "target"), ALLOWED_TRANSITIONS)
def test_allowed_transitions_pass(
source: DocumentLifecycleState, target: DocumentLifecycleState
) -> None:
assert is_allowed_transition(source, target)
# assert_transition does not raise.
assert_transition(source, target)
def test_disallowed_transitions_are_rejected() -> None:
"""Spot-check the most surprising disallowed pairs.
Exhaustive enumeration of every disallowed pair would just mirror the
transition table; this list captures the cases a reader is most
likely to argue about.
"""
forbidden = [
# Uploaded cannot skip directly to Ingested or Stale.
(DocumentLifecycleState.UPLOADED, DocumentLifecycleState.INGESTED),
(DocumentLifecycleState.UPLOADED, DocumentLifecycleState.STALE),
# Parsed cannot become Ingested without going through Chunked.
(DocumentLifecycleState.PARSED, DocumentLifecycleState.INGESTED),
# Stale is not directly reachable from Parsed (no per-store yet).
(DocumentLifecycleState.PARSED, DocumentLifecycleState.STALE),
# Failed cannot be reached as a "self-loop" — it always reflects a
# new failure, never an idempotent re-mark.
(DocumentLifecycleState.FAILED, DocumentLifecycleState.FAILED),
# Failed cannot jump directly to Ingested or Stale — must re-do
# the relevant pipeline step first.
(DocumentLifecycleState.FAILED, DocumentLifecycleState.INGESTED),
(DocumentLifecycleState.FAILED, DocumentLifecycleState.STALE),
]
for source, target in forbidden:
assert not is_allowed_transition(source, target)
with pytest.raises(InvalidLifecycleTransitionError) as excinfo:
assert_transition(source, target)
assert excinfo.value.source == source
assert excinfo.value.target == target
def test_document_default_state_is_uploaded() -> None:
doc = Document(filename="test.pdf", storage_path="/tmp/test.pdf")
assert doc.lifecycle_state == DocumentLifecycleState.UPLOADED
assert doc.lifecycle_state_at is None
def test_transition_returns_event_and_mutates_state() -> None:
doc = Document(id="doc-1", filename="t.pdf", storage_path="/tmp/t.pdf")
before = datetime(2026, 4, 29, 10, tzinfo=UTC)
event = doc.transition_to(DocumentLifecycleState.PARSED, now=before)
assert isinstance(event, DocumentLifecycleChanged)
assert event.document_id == "doc-1"
assert event.previous == DocumentLifecycleState.UPLOADED
assert event.current == DocumentLifecycleState.PARSED
assert event.at == before
# State mutated on the dataclass.
assert doc.lifecycle_state == DocumentLifecycleState.PARSED
assert doc.lifecycle_state_at == before
def test_invalid_transition_does_not_mutate_state() -> None:
doc = Document(id="doc-1", filename="t.pdf", storage_path="/tmp/t.pdf")
assert doc.lifecycle_state == DocumentLifecycleState.UPLOADED
with pytest.raises(InvalidLifecycleTransitionError):
doc.transition_to(DocumentLifecycleState.STALE)
# State is untouched.
assert doc.lifecycle_state == DocumentLifecycleState.UPLOADED
assert doc.lifecycle_state_at is None
def test_idempotent_self_transitions_emit_event() -> None:
"""Self-loops (re-parse, re-chunk, re-push) are explicitly allowed
so the pipeline can drive transitions without checking the source
state first. They still emit an event for observability."""
doc = Document(id="doc-1", filename="t.pdf", storage_path="/tmp/t.pdf")
doc.transition_to(DocumentLifecycleState.CHUNKED)
assert doc.lifecycle_state == DocumentLifecycleState.CHUNKED
event = doc.transition_to(DocumentLifecycleState.CHUNKED)
assert event.previous == DocumentLifecycleState.CHUNKED
assert event.current == DocumentLifecycleState.CHUNKED

View file

@ -1,10 +1,11 @@
"""Tests for persistence repositories using a temporary SQLite database."""
from datetime import datetime
from datetime import UTC, datetime
import pytest
from domain.models import AnalysisJob, AnalysisStatus, Document
from domain.value_objects import DocumentLifecycleState
from persistence.analysis_repo import SqliteAnalysisRepository
from persistence.database import init_db
from persistence.document_repo import SqliteDocumentRepository
@ -81,6 +82,46 @@ class TestDocumentRepo:
deleted = await document_repo.delete("nonexistent")
assert deleted is False
async def test_default_lifecycle_state_is_uploaded(self, document_repo):
"""Fresh document round-trip preserves the default Uploaded state."""
doc = Document(id="doc-1", filename="t.pdf", storage_path="/tmp/t.pdf")
await document_repo.insert(doc)
found = await document_repo.find_by_id("doc-1")
assert found is not None
assert found.lifecycle_state == DocumentLifecycleState.UPLOADED
assert found.lifecycle_state_at is None
async def test_update_lifecycle_persists_state_and_timestamp(self, document_repo):
doc = Document(id="doc-1", filename="t.pdf", storage_path="/tmp/t.pdf")
await document_repo.insert(doc)
when = datetime(2026, 4, 29, 12, 0, tzinfo=UTC)
await document_repo.update_lifecycle("doc-1", DocumentLifecycleState.PARSED, when)
found = await document_repo.find_by_id("doc-1")
assert found is not None
assert found.lifecycle_state == DocumentLifecycleState.PARSED
assert found.lifecycle_state_at is not None
assert found.lifecycle_state_at == when
async def test_lifecycle_state_round_trips_for_each_value(self, document_repo):
"""Every enum value must serialize cleanly into and out of SQLite."""
when = datetime(2026, 4, 29, 12, 0, tzinfo=UTC)
for value in DocumentLifecycleState:
doc = Document(
id=f"doc-{value.value}",
filename="t.pdf",
storage_path="/tmp/t.pdf",
lifecycle_state=value,
lifecycle_state_at=when,
)
await document_repo.insert(doc)
found = await document_repo.find_by_id(f"doc-{value.value}")
assert found is not None
assert found.lifecycle_state == value
class TestAnalysisRepo:
async def _insert_doc(self, document_repo):

View file

@ -9,8 +9,6 @@ RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
ENV NGINX_MAX_BODY_SIZE=200M
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View file

@ -20,6 +20,6 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 900s;
proxy_send_timeout 900s;
client_max_body_size ${NGINX_MAX_BODY_SIZE};
client_max_body_size 5M;
}
}

View file

@ -1,3 +1,22 @@
/**
* Canonical document lifecycle state mirrors the backend enum
* `DocumentLifecycleState` (see `domain/value_objects.py`).
*
* - `Uploaded` raw file persisted, no parse yet
* - `Parsed` conversion produced a document tree
* - `Chunked` chunker produced a draft chunkset
* - `Ingested` chunkset has been embedded into at least one store
* - `Stale` chunkset edited after a successful push (per-store concept)
* - `Failed` a pipeline step failed; recoverable by retry
*/
export type DocumentLifecycleState =
| 'Uploaded'
| 'Parsed'
| 'Chunked'
| 'Ingested'
| 'Stale'
| 'Failed'
export interface Document {
id: string
filename: string
@ -5,6 +24,10 @@ export interface Document {
fileSize: number | null
pageCount: number | null
createdAt: string
/** Canonical lifecycle state. Drives the status badge in `/docs`. */
lifecycleState: DocumentLifecycleState
/** ISO timestamp of the last lifecycle transition (UTC). */
lifecycleStateAt: string | null
}
export interface PipelineOptions {

View file

@ -20,6 +20,6 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 900s;
proxy_send_timeout 900s;
client_max_body_size ${NGINX_MAX_BODY_SIZE};
client_max_body_size 5M;
}
}