docs/references/knowledge/operation-guards.md
This document records the guard and recovery semantics for the three caller-facing knowledge item operations:
addItemsdeleteItemsreindexItemsThe operations intentionally do not share one generic validation pipeline. They share small guards where the semantics match, but each operation keeps its own explicit flow because their state transitions and enqueue-failure behavior are different.
assertBaseCanRunRuntimeOperationUsed by operations that create or rebuild runtime work on an existing base.
addItems: rejects failed bases.reindexItems: rejects failed bases.deleteItems: does not use this guard. Deleting a failed base's items must remain possible so callers can clean up recoverable or partially migrated data.KnowledgeItemService.getOutermostSelectedItemIdsUsed by subtree id-based operations: deleteItems and reindexItems.
baseId.This helper is not used by addItems because addItems receives new item payloads, not persisted item ids.
Any non-delete subtree status update must reconcile parent containers outside the updated subtree. For example, if a child subtree is marked failed after a scheduling failure, the parent directory must also be recalculated so it does not remain processing without active work.
Subtree membership must be resolved in the same serialized write transaction as the status write. Do not precompute subtree ids before entering DbService.withWriteTx; a concurrent create/delete between the read and update can leave descendants visible or reconcile containers against stale membership.
Final hard deletes remove Knowledge-owned vectors, raw files, and knowledge_item rows. Knowledge create/index no longer registers FileManager refs, so deleteItemsByIds does not perform a FileManager ref cleanup step.
deleteItemsByIds may delete explicit ids and rely on the groupId cascade for descendants; file bytes are purged by the workflow cleanup utilities before row deletion.
assertSubtreesCanReindexUsed only by reindexItems.
completed or failed.idle, preparing, processing, reading, embedding, or deleting.This is the backend authority for user-triggered reindex. UI may hide the reindex action for non-terminal rows, but the service guard must still reject stale or direct calls.
Used by listItemChunks. (The chunk-level delete deleteItemChunk was removed with the per-base index store cutover — chunks are derived index rows now, replaced wholesale by rebuildMaterial.)
assertBaseCanRunRuntimeOperation.baseId.completed.directory list requests, also rejects if any descendant is deleting.The UI should only expose chunk viewing for completed rows, but the service guard remains the backend authority for stale or direct IPC calls. The extra container descendant check exists because container reconciliation ignores deleting children, so a container can stay completed while cleanup is still pending below it.
addItemsaddItems accepts new item payloads and creates persisted knowledge_item rows before scheduling the first workflow jobs.
addItems(baseId, inputs)
-> reject failed base
-> no-op on empty inputs
-> under same-base mutation lock:
create each item
set root status to preparing for containers
set root status to processing for leaves
rollback created rows if create/status update fails
-> schedule each accepted item
container -> knowledge.prepare-root
leaf -> knowledge.index-documents
invalid -> mark item failed, no job
deleting -> skip
-> if enqueue throws:
mark accepted items that did not finish scheduling as failed
rethrow
addItems writes an active status before enqueueing. If enqueue fails after the mutation block, the row would otherwise stay in preparing or processing without a durable job to advance it.
The compensating rule is:
failed;This prevents stuck active rows while avoiding deletion of rows that may already be referenced by a queued job.
deleteItemsdeleteItems operates on existing item ids and is modeled as a durable cleanup state machine.
deleteItems(baseId, itemIds)
-> de-duplicate ids
-> load selected items
-> reject items outside baseId
-> collapse nested selections to top-level roots
-> no-op if no roots remain
-> under same-base mutation lock and one DB transaction:
mark selected root subtrees deleting
enqueue knowledge.delete-subtree
idempotency key = knowledge:${baseId}:${sorted root ids}:delete
-> if the transaction or enqueue throws:
roll back the deleting status write
rethrow
deletingThe deleting status write and durable job enqueue share one transaction. If enqueueTx throws, the transaction rolls back, so the rows retain their previous status and remain visible to the user. No committed delete intent exists for startup recovery to resume.
Startup recovery still scans committed deleting roots once and re-enqueues cleanup jobs best-effort. That scan covers rows left behind after an already-enqueued cleanup is interrupted or fails; it is not the fallback for a synchronous enqueueTx failure:
deleteItems enqueue failure
-> roll back rows to their previous status
-> throw the enqueue error to the caller
onAllReady
-> scan previously committed deleting root groups
-> enqueue knowledge.delete-subtree in bounded chunks
-> log scan or enqueue failures without retrying in-session
This keeps delete admission atomic while retaining recovery for cleanup work that had already become durable.
failedknowledge.delete-subtree is responsible for removing vector artifacts, deleting Knowledge-owned raw files, and deleting the resolved knowledge_item rows. If that job fails or is cancelled after rows were already marked deleting, the rows must stay deleting.
Do not convert these rows to ordinary failed items as a terminal fallback:
deleting is the state that hides requested-deletion content from default list, search, and RAG reads;failed means an indexing or preparation workflow failed, so list and search paths may treat the item as visible user data;deleting -> failed can make stale chunks searchable again;The recovery path for failed delete cleanup is to keep deleting, then let JobManager retry an existing knowledge.delete-subtree job or startup recovery enqueue another cleanup job for orphan deleting roots. If the product needs a user-visible terminal delete failure later, add an explicit delete-failure state or job-level UI, and keep that state excluded from default list, search, and RAG reads.
reindexItemsreindexItems operates on existing item ids but does not change item state in the caller-facing entrypoint.
reindexItems(baseId, itemIds)
-> reject failed base
-> de-duplicate ids
-> load selected items
-> reject items outside baseId
-> collapse nested selections to top-level roots
-> no-op if no roots remain
-> reject unless every selected root subtree is completed or failed
-> enqueue knowledge.reindex-subtree
idempotency key = knowledge:${baseId}:${sorted root ids}:reindex
User-triggered reindex is intentionally an offline rebuild of an existing subtree, not a cancellation or preemption primitive.
Allowing reindex while a subtree is still preparing, processing, reading, or embedding would force reindex-subtree to coordinate with active indexing and expansion jobs. That reintroduces cancellation races: old jobs may still be reading sources, writing vectors, recording indexed paths, or expanding children while the reindex job is deleting vectors and resetting rows.
The simpler rule is:
completed or failed before the user can reindex;deleting intent is written;The reindex entrypoint only accepts the durable job. It does not set roots to preparing or processing before enqueueing.
The reindex job owns the destructive and stateful work:
deleting after the entrypoint guard;scheduleItem for each selected root.Because the entrypoint does not write an active status before enqueueing, enqueue failure can be reported directly without leaving stuck active rows.
reindexItems rejects deleting before enqueue, and reindex-subtree treats deleting as a higher-priority state if delete wins the race after enqueue:
deleting;This prevents a later reindex request from cancelling delete cleanup or turning a deleting row back into preparing / processing.
These two deleting checks are intentional, even though the entrypoint already rejects deleting subtrees. They cover the window between enqueue and job execution while preserving the rule that delete is always available.
After the reset mutation, selected roots are deliberately visible as preparing or processing before their follow-up jobs are scheduled. This keeps the UI honest: a user-triggered reindex immediately appears as active work.
Because those active statuses are written before scheduleItem, the handler must compensate if scheduling fails. The failing roots are marked failed so the UI does not show stuck active work without a durable job. Do not remove this compensation unless reindex introduces a separate non-active pending state, such as a dedicated reindexing or pending_reindex lifecycle state.
Knowledge source files are Knowledge-owned raw files, not FileManager refs. Reindex must not detach FileManager refs for selected leaf roots because there are none to detach; the root knowledge_item rows remain alive and read data.relativePath / data.indexedRelativePath.
Leaf indexing reads from the current knowledge_item.data and rewrites derived vector material. Stale descendants from a container expansion are removed through the delete-subtree cleanup path, which purges vectors/files and then deletes rows.
prepare-rootprepare-root is an internal job, but it creates child rows and schedules their leaf indexing jobs, so it has its own cleanup and compensation rules.
knowledge.prepare-root(baseId, itemId)
-> skip missing or deleting roots
-> under same-base mutation lock:
find previous descendants
ignore descendants already deleting
clear vectors for removable leaf descendants
purge Knowledge-owned raw/indexed files for removable leaf descendants
delete removable descendants by resolved id
-> under same-base mutation lock:
re-read root and skip if it is now missing or deleting
expand source into new child rows
set root status processing
-> schedule each recreated leaf
if scheduling fails:
mark leaves that did not finish scheduling failed
leave already scheduled leaves alone
rethrow
The stale expansion cleanup clears derived vector material and purges Knowledge-owned raw/indexed files for removable leaf descendants before deleting resolved descendant rows, so a retry does not leave stale vectors or stale Knowledge-owned files from a previous partial expansion.
The second root read closes the race where prepare-root loads an active root, then a delete request marks that root deleting before expansion starts. Once a root is deleting, no new children may be created under it.
The child scheduling compensation mirrors addItems: once a child job was accepted, the row is left alone; the failing child and later children are marked failed so no processing leaf remains without a job.
KnowledgeService does not cancel knowledge jobs during service shutdown. The indexing handlers (prepare-root, index-documents, check-file-processing-result) and reindex-subtree use JobManager recovery: 'abandon' — an app restart never silently resumes them, which would otherwise re-spend the paid embedding API. KnowledgeIngestionService.recoverInterruptedItems() runs on startup and parks any item left in an active status by an interrupted job as failed. Only delete-subtree uses recovery: 'retry', so unfinished pending, delayed, or running delete jobs are left for JobManager startup recovery instead of being terminal-cancelled.
When changing these operations, check the operation-specific failure behavior before extracting shared code.
| Operation | Failed base | Root collapse | Extra status guard | State before enqueue | Enqueue failure |
|---|---|---|---|---|---|
addItems | Reject | N/A | N/A | preparing / processing | Mark unscheduled accepted rows failed |
deleteItems | Allow | Yes | N/A | deleting (uncommitted; same transaction as enqueue) | Roll back to the previous status |
reindexItems | Reject | Yes | Entire selected subtree must be completed or failed | None | Throw; no active state was written |
listItemChunks | Reject | N/A | Requested item must be completed; container list rejects deleting descendants | N/A | N/A |
Prefer shared helpers for exact common behavior, such as base-state guards, base ownership checks, root collapse, queue names, and idempotency key builders. Keep operation flows explicit when the state or recovery semantics differ.