ADR-030: Per-project knowledge-base document scope and recoverable soft-delete
Status: Reverted (2026-06-20) — see the revert note below Date: 2026-06-19 · reverted 2026-06-20 Deciders: Yuxing Wu Related: specs 006-knowledge-base, 007-memory; completes ADR-028 (co-managed documents); builds on ADR-012 (files as truth); sibling of ADR-026 (memory via MCP); the slice that completes the unified-knowledge redesign (知识 = 记忆 + 文档 × 全局 / 项目)
⚠️ Reverted 2026-06-20. This ADR's decisions were withdrawn one day after they landed. The unified 知识 surface they were built to support proved to conflate two distinct workflows — user-uploaded documents (知识库) vs agent-written memory (记忆) — so the UI was split back into two separate surfaces. With the unified view gone, the rationale for per-project document scope and document soft-delete (both of which existed only to align documents with memory's project axis inside that one merged view) no longer held, and both were reverted along with it. The system returns to the ADR-028 baseline: knowledge-base documents are co-managed at global scope with hard delete. Memory's own global / per-project scoping is unaffected — it predates this ADR (see ADR-026). The original decision is preserved verbatim below for the record.
Context
ADR-028 shipped the co-management core at global scope and explicitly deferred two pieces "to the later slice that surfaces them in the unified 知识 UI, to keep this change reviewable and avoid building backend state with no UI":
- Per-project document scope — the 全局 / 项目 axis for documents.
- Recoverable soft-delete (trash / restore) — "needs its own UI to be useful".
This ADR makes both decisions, as that unified-知识 slice lands.
Two facts frame it:
- Memory (007) already has a two-layer scope: a global store (the
WORKSPACE_GLOBALsentinelproject_id) and a per-project store keyed by a project ULID deterministically derived from the agent's git-root path (ADR-026 era). The KB face carried the sentinel on every row. The unified 知识 model — 知识 = 记忆 (notes) + 文档 (documents) × 全局 / 项目 — needs documents to live per-project too, so a project's notes and documents sit together under one scope. - Delete was a hard delete. ADR-028 accepted that as a temporary measure, protected by the F01 audit trail and the per-document lock. With the unified UI giving documents a real management surface, a recoverable trash is now both feasible and expected.
Decision
1. Per-project document scope
- A KB document carries a real
project_id: theWORKSPACE_GLOBALsentinel for a global document, or a project ULID (deterministic Crockford-base32 of the git-root path — the sameproject_ulidmemory uses) for a project-scoped document.project_idwas already a column ondocuments; this populates it for the KB face instead of always stamping the sentinel. - On-disk layout gains a per-project subtree, back-compatibly. Global documents stay at
knowledge/<kb>/docs/+raw/(existing documents are not relocated); project documents live atknowledge/<kb>/projects/<ulid>/docs/+raw/. The asymmetry is deliberate — it mirrors memory'sglobal/+projects/<ulid>/split without moving any already-stored global document. - Re-upload identity is scoped to
(kb, project_id). The same filename in global vs a project, or across two projects, is an independent document. (find_by_filenamealready takesproject_id.) - List, read, grep, and keyword / vector search are scoped to the resolved
project_id. Grep is naturally scoped (it walks the scope'sdocs/dir); keyword / vector filter ondocuments.project_id. - Scope resolution at the boundary, not in the service. The REST ingest endpoint accepts an explicit
project_id(the unified UI sends the scope the user is viewing); agent MCP writes resolve the project from the agent's reportedcwd(git-root →project_ulid), defaulting to global when there is no git root or nocwd. The git-root → ULID helpers (scope_fs) move frominfrastructure/memory/into the sharedinfrastructure/knowledge/substrate so both faces share one implementation without a forbidden cross-kind import.
2. Recoverable soft-delete (trash / restore)
documentsgains a nullabledeleted_at. Deleting a live document is a soft-delete: it removesdocs/<id>.mdand the index rows (chunks / FTS5 / vec) but KEEPSraw/<id>.<ext>and thedocumentsrow withdeleted_atset. The document leaves every live read — list, get, search, grep, metrics, and the re-upload match — all of which filterdeleted_at IS NULL.- Restore re-converts the document from its kept
raw/original, regeneratesdocs/<id>.md, re-indexes, and clearsdeleted_at.source_moderesets toconverted: a restored document is freshly converted from the original. Any pre-deletion body edits are not recovered — soft-delete removed the edited markdown and only the originalraw/is kept (consistent with ADR-028's "keep only the latest original — no version history"). - Deleting an already-trashed document purges it permanently (removes
raw/+ the row). So "delete" on a live document trashes it; "delete" on a trashed document is the explicit permanent purge. A KB-level delete still hard-removes everything, including the trash. - The lock (FR-021) still guards: a locked document cannot be soft-deleted, purged, or restored-over.
- Reindex-on-read must not resurrect a tombstone, and the guard is two-sided: because soft-delete removes
docs/<id>.md, the scan's rebuild branch (file present, no row → reconstruct) never sees the file; and because the prune branch (row present, file gone → hard-delete) operates over live rows only (list_documentsfiltersdeleted_at IS NULL), it never hard-deletes the tombstone. The keptraw/is intentionally not scanned — onlydocs/is the markdown truth — so it cannot trigger a rebuild. - Audit: soft-delete records
KB_DOCUMENT_DELETED(the existing event, now meaning "moved to trash"); restore recordsKB_DOCUMENT_RESTORED; permanent purge recordsKB_DOCUMENT_PURGED.
Why the shared-repo filter is safe for memory
The documents table and its repo are shared with the memory face. Adding deleted_at IS NULL to the repo reads is a no-op for memory rows: memory never sets deleted_at (memory's forget is a hard delete of a fact file + its row), so its rows always satisfy the predicate and its behavior is unchanged.
Consequences
Positive
- Completes the unified 知识 model: a project's notes (memory) and documents (KB) now live under one 全局 / 项目 axis, surfaced together.
- Agent deletes become recoverable (soft-delete) — a strictly safer default for co-managed documents than the ADR-028 hard delete, while purge stays explicit.
- The existing global corpus is untouched on disk; per-project documents are additive.
Negative / trade-offs
- Restore loses body edits (it re-converts from
raw/). Accepted: there is no version history (ADR-028), and a curated/edited document should be locked, which prevents its deletion entirely. - The trash is unbounded until purged or the KB is deleted (no auto-expiry). Accepted for a single-user local tool; purge is explicit and a KB-delete clears it.
search/grepgainproject_idscoping while the FTS / vec index stays keyed by(kind, resource_name). grep scopes by the per-scopedocs/directory. keyword search filters at the existing FTS↔documentsJOIN (AND d.project_id = :pid), soLIMIT kreturns the true top-k in-scope — no over-fetch. vector search is the exception: the sqlite-vec KNN has noproject_id, so it over-fetches the KNN, filters at the JOIN, and truncates totop_k— its recall is bounded by the over-fetch ceiling, accepted because vector is opt-in and corpora are small (SC-002 ≤ 50 documents).- A document's scope is fixed at ingest; "moving" a document between global and a project is a re-ingest, not a metadata flip. Accepted to keep the on-disk truth and the stable id intact.
Alternatives considered
Per-project = a separate KB resource per project (mirroring how memory makes a project-<ulid> Resource). Rejected: it fragments one corpus into N resources and breaks "one KB, scoped views". Scoping within one KB resource by the project_id column keeps a single corpus with a scope axis.
Soft-delete keeps docs/<id>.md (move it to a trash/ subtree) to preserve edits. Rejected for this slice: it complicates reindex-on-read (the scan would have to learn to skip a trash subtree) and re-introduces version-history-like state; re-converting from the kept raw/ is simpler and keeps "files in docs/ are the live truth" honest.
Tombstone via a status / boolean column instead of a deleted_at timestamp. Rejected: deleted_at doubles as the trash-ordering key and the "when" for the restore / audit UX; a boolean carries less for no saving.
No purge — a KB-delete is the only cleanup. Rejected: an unbounded trash with no per-document purge is a footgun. Making "delete a trashed document = purge" is the least-surprising affordance and needs no new verb.