mono.embedding_ingest_registry.document.source field, because there is deliberately no separate “file” and “URL” column to get out of sync.document row appears immediately with extraction_status = pending. In the background the server extracts the text, running OCR if the file is a scan, writes the text to extracted_text_path, and flips the status to ready. An embedding_job is then queued. When it finishes, the document's chunks exist as embedding rows with vectors, and the document can be attached to a run as context.document, embedding_job, embedding| If… | Then… |
|---|---|
| Extraction fails — a corrupt file, a dead link, an unreadable scan | extraction_status = failed; the document is listed but not attachable, and the admin can fix the source and retry. |
| The source is replaced | Status goes to stale and a fresh extraction is queued. The old text stays live and searchable the entire time, and the new text becomes visible only at the atomic swap; if the re-extraction fails, the old text is still served. |
A running or paused run attaches the document as context | The swap is deferred until the run finishes, so a run's retrieval corpus never shifts underneath it mid-study. |
| The embedding job fails | embedding_job.status = failed with attempts and error recorded — visibly stuck rather than silently missing. |
Someone tries to attach a pending, stale or failed document as context | Refused: only ready documents attach. |
| They try to delete a document an experiment still attaches | Refused (RESTRICT). Retiring it (retired_at) hides it from new studies while it stays in place for the experiments referencing it; once detached everywhere it can be decommissioned, which also removes its embedding rows. |
document row moves pending → ready with a non-null extracted_text_path, an embedding_job reaches done, and at least one embedding row exists; and an unreadable file ends at failed without ever nulling the path.extraction_status, and why the path never goes null.documents.html) as each row's status badge. stale is what makes a replacement non-disruptive: it is a readable state, not an outage.embedding_job.status.embeddings-setup.html). A failed job is a visible state with a retry path, never a silent gap.Documents are the material the AI is supposed to answer from. Getting them in has three stages, and each can fail on its own, which is exactly why each has its own visible status.
First the file arrives, by upload or from a web address. Either way it is recorded the same way, in one field, so there is never a question of which of two columns is the real source.
Then the text is pulled out, with OCR if it is a scan. There is a subtle but important design choice here: the extracted-text file keeps the same path forever and is never blanked out. When you replace a document, a fresh extraction is written alongside and swapped in at the last moment. The result is that the old text stays searchable the entire time the new one is being prepared, instead of the document dropping out of the system while it reprocesses.
Finally the text is cut into chunks and turned into vectors, so the AI can retrieve the relevant passages rather than being handed the whole document. That work is queued, and if it fails you can see that it failed, along with how many times it tried and what went wrong.
document.source and the never-nulled extracted_text_path and extraction_status pairing are exactly what these two state machines need.