Repository navigation
Knowledge retention and central storage: copy what we do not control, and detect a vanished source first #17538
Description
Owner reasoning (2026-09-26), which specifies this precisely
original document stays in original place — do we need to retain artifact, after it is vectorised
if the source is gone we can't use it any more
if we want to retain some knowledge we have to copy to our controlled location — this is where central storage comes in
That resolves retention into a policy rather than a yes/no, and it scopes central storage to a retention tier, not a store-everything layer.
The policy this implies
| Source | Who controls it | Treatment |
|---|---|---|
| Watch folder on our own fileserver | us | pointer + content hash — copying would duplicate and drift |
| Connector to third-party SaaS (Confluence, Notion, Slack, Jira, GDrive…) | them | pointer can die → copy to controlled storage |
| Crawled web page | nobody | will die → copy |
| Upload | no durable origin at all — the file was on someone's laptop | copy, or accept that re-extraction means re-upload |
Copying everything is the store_authority rule-2 defect (a second original that drifts). Copying nothing loses the ability to ever re-extract. Copying what we do not control is the only position that is neither.
What is lost today when a source disappears — narrower than it sounds
The extracted text is already retained — that is the fact content, and it stays in the vector store and graph. What dies with the source is the ability to re-extract: better OCR later, fixed table parsing, a corrected extractor. So the exposure is "permanently stuck with the extraction quality we had on ingest day", not "the knowledge is gone".
That is a real cost — #13884 exists because scanned PDFs currently extract to nothing — but it is bounded, and it is what makes the copy tier worth scoping rather than skipping.
The prior gap: disappearance is not detectable
Nothing detects a vanished source. services/kb_folder_watcher.py has no deletion handling — one "removed successfully" string at :277, no on_delete, no unlink path. A deleted watched file, a deleted Confluence page and a 404'd URL all leave the fact in place pointing at a locator that no longer resolves, and no query can identify which facts have lost their evidence.
So the retention policy cannot currently be sized: nobody can measure how much of the KB has a dead source.
What exists to build on: nothing
- No MinIO, S3-compatible store, NFS or shared volume in the fleet deployment.
integrations/cloud_integration.pyis an outbound agent tool (list_s3_bucketsagainst a user's own AWS), not storage infrastructure.- Redis and Postgres are already networked. ChromaDB supports both
HttpClient(central) andPersistentClient(per-node disk), with the selection inknowledge/backends/async_chromadb_adapter.py:199(Fix blocking ChromaDB operations in async functions #369) — butcodebase_analyticsruns its ownPersistentClientconcurrently with the KB's, so at least part of the vector store is node-local.
Acceptance criteria
- Detect first. Source-liveness checking and watch-folder deletion handling, with a
source_stateon the fact. Cheap, and it makes the problem measurable. - Measure second. How many existing facts have an unresolvable source. That number decides how much retention is worth before anything is built.
- Then storage. A node-shared, content-addressed store for retained copies — reachable from every fleet node, hash-keyed so the same document arriving twice is stored once. S3-compatible self-hosted (MinIO) is the obvious shape for this fleet; that is a recommendation, not a decision.
- Retention is per source class, per the table above — not global, not per-file-opt-in.
- The
DOCUMENTentity from Umbrella: knowledge/memory is the relation system of record and every module a consumer — today nothing outside knowledge calls it #17526 carries one of two locator kinds: an external URI for sources we do not copy, or an internal object key for retained copies. One entity type, two resolutions. - Settle the deployment fact: is ChromaDB running central (
HttpClient) or per-node (PersistentClient) in the fleet today? Readable only from the running system, not the repo. If per-node, a document ingested on one node is invisible to the others, which is a larger problem than retention.
Why this is ordered detect → measure → build
The retention tier is the expensive part and its size depends entirely on how often sources actually vanish. Building the store first means guessing that number. The detection work is small, independently useful, and turns the guess into a measurement.
Related: #17526 (DOCUMENT entity / provenance), #17527 (intake), #17537 (roadmap), #13884 (scanned PDFs extract to nothing — the concrete re-extraction case).
Activity
mrveiss commentedon Sep 26, 2026
Correcting the storage recommendation — owner decision, and the precedent is already in this repo.
The acceptance criteria above suggest "S3-compatible self-hosted (MinIO)". Disregard that. The mechanism is a mounted directory, exactly as the ingest side already works.
knowledge/connectors/file_server.py:8states it:The connector does not speak SMB. It takes a
Path, and the protocol is an infrastructure concern handled by the mount. Retention should be the same shape: AutoBot writes to a path; the fleet mounts that path via CIFS/Samba; the code stays protocol-agnostic and single-node local still works with no special case.That is better than an object store here for concrete reasons, not just consistency:
pathlibandhashlib, both already used byfile_server.py.Revised criterion
S3-compatible object store→ a mounted directory, configured as a path, mounted CIFS/Samba across the fleet. Same pattern asfile_server.py(Knowledge: Source Connector Framework (Backend Architecture) #1254). Single node uses a local directory with no code difference.The one thing that must not be copied from the ingest side
file_server.pyreads a mount. Retention writes to one, with content that arrived from outside — and that inverts the risk.Retained copies must be content-addressed — named by hash, never by the user's filename. This repo has scar tissue precisely here: #17300 and #17302 (an unvalidated
storage_pathbecame an arbitrary-write primitive becausepathlibdiscards everything left of an absolute segment), and today #17492, where a filename of fullwidth confusables survived a length bound, NFKC-normalised into./../x, and collapsed two attachments onto one path.Hash-naming removes that entire class rather than defending against it: no attacker-supplied component ever reaches the path, dedup comes free, and the same document arriving from two connectors is stored once. Keep the original filename in metadata where it belongs.
Two further notes for whoever builds it:
/var/log/redisvs/var/log/redis-stackownership mismatch today (security(redis): the role's Redis Stack config is never read — requirepass is empty on a provisioned node, and one path has four spellings #17434). Pin the mount options in the ansible role rather than relying on defaults.The detect → measure → build ordering above is unchanged: the mount does not need to exist before the liveness work, and the measurement is what sizes it.
mrveiss commentedon Sep 26, 2026
Added requirement (owner, 2026-09-26):
This is new surface — there is no document-serving precedent in the backend today. The only
StreamingResponseuses are SSE and NDJSON (a2a.py,adapters.py); nothing returns a stored artifact, andknowledge_graph_routes.py:260's/documents/{id}/overviewreturns graph overview, not the file.Three things follow, and two are not obvious.
1. Content-addressing and "readable" pull against each other — metadata resolves it
Hash-named files have no extension and no type. So the retained record must carry
original_filename,content_typeandsizealongside the hash, and the serve route setsContent-DispositionandContent-Typefrom that metadata, never from the stored path. This is not a nuisance — it is the reason hash-naming is safe: the path contains nothing attacker-supplied, and the display name is data rather than a filesystem decision.2. Serving retained documents can leak across the credential boundary — this is the sharp one
A retained copy was fetched with someone's credential. A Confluence page pulled through one user's OAuth is now a file on a share, and a naive serve route makes it readable by anyone with a knowledge-base account — including people who could not open it at the source.
Retention must not become privilege escalation. The copy inherits the access constraints of its origin, or the feature quietly flattens every source system's permissions into "anyone with AutoBot".
This is checkable and partly already solved:
ConnectorCredentialStoredelegates to the canonical secrets manager withaccessed_byattribution, and the memory graph already declaresSECRET,owns,shared_with,used_secretandperformed_by(#608). So "which credential fetched this document" is expressible onceDOCUMENTexists (#17526) — which makes the authorisation decision answerable rather than guessed. Unverified: whetherused_secretis actually emitted; the same file records a case whereuses_secretwas written whileused_secretwas declared with no writer.3. "Human readable" should mean two layers, not one
Serve the original artifact and the extracted text side by side. That makes extraction quality inspectable rather than assumed, which is exactly what #13884 needs — a scanned PDF currently extracts to nothing, and today the user cannot see that the indexed content is empty while the document plainly is not. Showing both turns a silent quality failure into an obvious one.
It also serves the hub goal directly: provenance you can open, not merely cite.
Acceptance criteria (added)
Content-Typeand filename resolved from metadata, not from the stored path.Content-Disposition: attachmentfor anything not explicitly safe to render, and the existing_sanitize_html_contentpath reused for HTML rather than a second implementation. Retained artifacts are externally-authored bytes — HTML and SVG are active content.mrveiss commentedon Sep 26, 2026
Clarification (owner, 2026-09-26) — and it corrects the bare-hash naming I proposed above:
So the retained store must be browsable and openable by a person with a file manager and ordinary software, not only resolvable by the application. A file named
a3f9c2e1…with no extension satisfies my earlier safety argument and fails this one completely — you cannot tell what it is, and nothing will open it.Both properties are obtainable; they are not in conflict once the extension is treated as derived rather than supplied.
Revised naming
<hash>.<detected-extension>— for examplea3f9c2e1….docx../../x).verified_upload_extension(filename, file_content)— "the bytes outrank the name" (Verify ZIP-based office/ODF formats by content, not extension alone #16773) — andmedia/document/zip_formats.pyalready sniffs ZIP containers by their marker part rather than trusting the suffix.Retain two artifacts, not one
.docx,.pdf,.md,.xlsx— openable with the software it was authored in..mdor.txt, alongside it.The second is the one that makes this durable. It is already produced at ingest (it is what gets vectorised), it costs nothing extra to write, and it means every retained document has a form that opens in any text editor forever — regardless of whether the original format is still supported, whether the proprietary reader still exists, or whether the extractor that produced it has since been replaced.
It also makes extraction quality directly inspectable: open the
.docxand the.mdside by side and the difference is visible. That is #13884's scanned-PDF case made obvious rather than silent — an empty.mdnext to a 40-page PDF needs no explanation.What this does not mean
No format conversion at ingest. Do not normalise
.docxto.mdand discard the original — that would be re-extraction's whole purpose thrown away, and a lossy conversion is exactly what you cannot redo once the source is gone. Keep both; convert nothing.Revised criteria
<content-hash>.<extension-derived-from-verified-content-type>— openable, with no caller-supplied path component..md/.txt.quarterly-report.docx, nota3f9c2e1….docx.mrveiss commentedon Sep 26, 2026
Layout proposal (owner: "make the folder structure and naming so it is usable and can be navigated by human").
Why not the usual two-view store
The standard answer — a flat
by-hash/canonical store plus aby-source/tree of symlinks — does not work here. This mount is CIFS/Samba, where symlinks require unix extensions that are commonly disabled, and hardlinks are unreliable across SMB implementations. A layout that depends on links will work in local development and break on the fleet.So: one layout, navigable directly, with identity carried in the filename rather than by the path.
Proposed structure
Concretely:
A person browsing the mount reads it without the application: where it came from → which instance → when → a recognisable name. Opening the
.docxworks; opening the.mdworks; the.jsonsays what it is.Why each part is shaped this way
<slug>— the original title, sanitised, so the file is recognisable. Derived from the source's own name, never used raw.__<hash12>— first 12 hex of the content hash. Gives identity and dedup detection while keeping the name readable; the double underscore makes the boundary unambiguous when parsing back..<ext>— from the verified content type, not the supplied name (verified_upload_extension, "the bytes outrank the name", Verify ZIP-based office/ODF formats by content, not extension alone #16773).<YYYY>/<MM>— bounds directory fan-out. A single directory with tens of thousands of entries is slow on SMB and unusable in a file manager..jsonsidecar — original filename, source locator, fetching identity, ingest timestamp, extractor version, content hash.The sidecar is the durability argument
With it, the share is self-describing: if the knowledge database is lost or rebuilt, the mount still records what every file is, where it came from and when. Without it, a rebuilt index cannot re-associate documents with their provenance, and the copies become anonymous bytes — which is precisely the outcome retention exists to prevent.
CIFS constraints that must be handled in the sanitiser
These are not hypothetical; each one produces a real failure on a Samba mount:
CON PRN AUX NUL COM1-9 LPT1-9are unusable as components, with or without an extension. A Confluence space keyedAUXbreaks the path.< > : " \ | ? *and control bytes. Colons are common in page titles.report .pdfandreport.pdfmay collide.Reuse rather than reimplement:
transcriber/upload_security.py:77 generate_secure_filename,agent_loop/text_utils.py:16 slugify, andautobot_shared/security/path_validator.py(resolve_within_sandbox,validate_relative_path). Verify each against the CIFS list above before adopting it — they were written for local disk, and none of them was built with reserved device names in mind.Dedup without links
Same content arriving twice — a file both watched and fetched by a connector — is detected by hash in the index, and the second ingest records a reference to the existing path rather than copying. The duplicate does not appear twice on the mount; the fact carries both source locators.
Acceptance criteria (added)
.md, and.jsonsidecar.mrveiss commentedon Sep 26, 2026
Recording the seam for the watch-folder half, found while fixing the ingest path in #17022/#17531 — not building it here.
The gap is confirmed and narrower than "no delete handling": the handler has no delete event at all.
KBFolderChangeHandlerimplementson_created(:112) andon_modified(:118) and nothing else. Noon_deleted, noon_moved. So a watched file that is deleted or renamed leaves its fact in place, and the watcher never learns the source is gone — there is no code path that could mark it, not a path that marks it wrongly.Two things now exist that make this cheap, which is the reason for this note.
First, the ingest metadata already carries the source path:
build_watch_metadatainservices/kb_watch_ingest.pywritesfile_path,filenameandfolder_idinto every fact's metadata. A vanished source is therefore findable without a schema change — the join key is already stored.Second,
services/kb_watch_ingest.pyis where the watcher's Knowledge Base calls live now, split out of the watcher so they can be exercised without standing up a filesystem observer. Amark_source_vanished(folder_id, file_path)besideingest_watched_fileis the natural shape, called from newon_deleted/on_movedhandlers, and it gets the same testability for free.One caution from the defect I was fixing. The watcher's KB write path had never executed — it called a method the KnowledgeBase does not define — and the reason nobody noticed for months is that the failure only ever incremented a bare
errorscounter. #17531 fixed that: failures now record what failed, andget_stats()surfaceslast_errorsper folder. Whatever marks a vanished source should report through the same place rather than adding a second silent counter, or detection will be as unobservable as the ingest was.Worth deciding explicitly rather than by default: whether a vanished source marks the fact, tombstones it, or deletes it. Deleting is a data-removal decision an agent proposes and never takes (#17038), so marking is the only option that does not need an approval gate designed alongside it.
mrveiss commentedon Sep 27, 2026
AC 6 answered from the running system: central server, and still not fleet-reachable
It is a central server — and it is bound to loopback only, so the consequence this AC warns about holds anyway, by a different mechanism than the one it anticipated.
Collected read-only from the deployment, nothing modified and no credentials used:
autobot-chromadb.service— loaded, active, running/api/v2/heartbeatand/api/v2/versionanswer on a loopback port; the v1 path returns Chroma's own "The v1 API is deprecated" messageautobot_code,autobot_docs, plus per-project, per-company and per-work-item collectionschroma.sqlite3, modified the same day as this readingPersistentClientdirectory is empty0.0.0.0What that means for retention
The feared case was per-node
PersistentClient. It is not that: one server owns the store, so two processes on this node share one view. But a loopback-bound server is unreachable from any other node, so another fleet node pointing anHttpClientat this host cannot connect — and would therefore need its own Chroma, with its own store. The outcome the AC names, "a document ingested on one node is invisible to the others", survives the answer.So this AC is answered, and it hands #17538 a second question rather than closing one: is the intended topology one shared vector store, or one per node? Retention cannot be designed without that, because a content-addressed store "reachable from every fleet node" (AC 3) has no meaning if the index pointing into it is node-private.
Stated limits
One observation outside this AC, for whoever owns it
Every other data service on this host binds all interfaces while Chroma binds loopback. That asymmetry is either deliberate or an oversight, and it is a question for the fleet-networking owner rather than for retention. Recorded here only because it came from the same reading; #17440/#17447 is the nearest prior art.
fix(kb): two overlapping sweeps can no longer undo each other (#17545)
mrveiss commentedon Sep 27, 2026
AC 3 — "reachable from every fleet node": measured, and it is a gap on this node
Measured on this host just now with
ss -ltn, service identified by the SSOT port (ssot_config.py:221fixes ChromaDB at 8100):127.0.0.1— loopback only0.0.0.00.0.0.0and[::]A loopback-bound store is reachable from processes on that node and from nowhere else, so on the topology where the vector store is one shared authenticated store rather than a per-node copy, this bind cannot satisfy AC 3 — no second node can open the socket at all, with or without credentials.
The asymmetry is the part worth keeping: the two stores that are not the vector store already listen on all interfaces. So this is not a fleet-wide policy of keeping data stores local that Chroma happens to follow — Chroma is the outlier, and the direction of the fix is to bring it in line with Postgres and Redis, not to reconsider theirs.
Two limits on that measurement, stated rather than left to be assumed:
ssrun without privileges does not print the owning process, so each row above is identified by port against the SSOT, not by inspecting the process. If some other process were holding 8100, the row would be mislabelled — the bind itself is what was measured.A near-miss worth recording here rather than only in a message
While gathering this I initially took port 8000 for the vector store. It is not: a request to it returns a FastAPI 404 and the root path identifies it as the SLM Backend. Chroma is 8100, per the SSOT line above. Both happen to be loopback-bound, so a probe of the wrong port would have produced the right verdict for the wrong store — the kind of answer that is correct, complete, and about a different question, and that nothing downstream would have caught.
Anyone re-measuring this: identify the store by the SSOT port and confirm what answers there, rather than by the first port that looks like a service.
mrveiss commentedon Sep 27, 2026
AC 3 — what the target configuration is, and the order it has to happen in
Now that the topology is decided as one shared authenticated store, the gap is definable. Measured on this node; the deployed tree read
a4768fd323f5both before and after every observation below, so this is one result about one SHA. (That SHA wasorigin/mainat measurement time and is already behind it — parity does not survive the queue.)Current posture, measured
127.0.0.1:8100— loopback only (ss -ltn; port fromssot_config.py:221)0.0.0.0+[::], Redis0.0.0.0/api/v2/heartbeatand/api/v2/versionreturns HTTP 200 (/api/v1/heartbeatreturns 410 — v1 is gone, v2 is the live API)So today the store is unreachable from other nodes and unauthenticated to anything on this one. Its only protection is the network boundary.
What that means for the fix, and why the order matters
The target moves the store off the boundary that is currently protecting it. Opening the bind first would turn a local-only unauthenticated store into a fleet-reachable unauthenticated store — strictly worse than today, and reachable by a single config change made in good faith.
So the criterion should be sequenced rather than stated as one outcome:
AUTOBOT_CHROMADB_AUTH_TOKENalready exists (ssot_config.py:1604) and feedsCHROMA_SERVER_AUTHN_*throughutils/chromadb_auth.chroma_client_auth_kwargs, which every client path already calls (async_chromadb_client.py:527,chromadb_client.py:39,cli/doctor.py:74). The SSOT default is the empty string, documented as auth disabled. Evidence for this step is a credential-free request that is refused, not a configuration file that mentions the variable.AUTOBOT_CHROMADB_HOST,ssot_config.py:189, default127.0.0.1) — never a hardcoded address.Two things I did not establish
Suggested criterion wording
AUTOBOT_CHROMADB_HOSTand is not loopback in the fleet deployment (evidence: the listening socket, plus the SSOT read — no hardcoded address anywhere)feat(kb): detect whether a fact's source still resolves (#17545) (#17615
mrveiss commentedon Sep 27, 2026
AC 3 gap settled: the vector store's data endpoints answer unauthenticated too
The open question was whether authentication is enforced on the data endpoints while the liveness
endpoint is open. It is not. Measured on the deployment just now.
Method chosen deliberately: status codes only, response bodies discarded (
curl -o /dev/null). Nocollection was listed, no name read, no content touched. The question is about configuration and it is
answered without reading the knowledge base to settle it.
So the store is unauthenticated at the data layer, not merely at its health check. Its only protection
is the loopback bind.
What this settles, and what it does not
Settles: the sequencing constraint recorded earlier is confirmed rather than assumed. Authentication
must be in place before the bind widens, demonstrated by a refusal from an unauthenticated
caller. Widening first would expose a credential-free data layer to every node, which is strictly worse
than today's posture.
Does not settle: whether writes are equally open. Only reads were probed, and a write probe would
mutate state, so it is not an appropriate way to answer a configuration question. Assume writes are open
unless a refusal is demonstrated — that is the safe direction and it does not change the plan.
Cross-reference
A sibling store is in a worse position and already has a home at #17500: unauthenticated, no
password configured, and reachable beyond loopback. The class is the same — reachable implies
authenticated — and the ordering rule applies to both: credentials land before reachability widens,
never after. The vector store is the safer of the two today only because of where it sits, which is
precisely what the scaling work removes.
mrveiss commentedon Sep 28, 2026
Classification (for work assignment — not a fix proposal)
Scope:
kb(secondarychromadb)Primary files/dirs:
autobot-backend/knowledge/source_liveness.pyandautobot-backend/models/knowledge_fact.py(verified on main; the detect half, via feat(kb): no fact records whether its source still resolves — probe and measure before retention is scoped #17545)autobot-backend/services/kb_folder_watcher.pyandautobot-backend/services/kb_watch_ingest.py(verified on main; the watch-deletion half)autobot-backend/knowledge/backends/async_chromadb_adapter.py(verified on main)Umbrella: yes, and it is a container. Children:
AC3-5 (storage, per-class retention, the DOCUMENT locator) have no child issue. The children are the work.
Blocked by:
Read vs inferred: Read the issue body, its 9 comments and its sub-issue list, and verified the listed paths on main. AC6 is ticked from host evidence in comment 6.
Undetermined: whether the DOCUMENT entity (Umbrella: knowledge/memory is the relation system of record and every module a consumer — today nothing outside knowledge calls it #17526/feat(memory): ENTITY_TYPES has no artifact types — add DOCUMENT and CODE so provenance is expressible #17539) exists yet, and which nodes besides the measured one run a vector store.
Same-file note:
services/kb_folder_watcher.pyis also the primary file of bug(kb): the watch folder never notices a deleted or renamed file — no on_deleted, no on_moved, and change_type is decorative #17546, bug(kb): watch-folder modifications are gated on a per-process dict, so a file that predates the process is never re-ingested at all #17547 and Connectors must declare what they need, and test_connection must return why it failed #17554. Under the same-file rule, work on it goes to one session.Generated by Claude Code
mrveiss commentedon Sep 28, 2026
Chunking triage — a proposal, not an assignment
origin/mainsince 2026-08-14 references this issue.Nothing was relabelled, moved or closed by this pass.
mrveiss commentedon Sep 28, 2026
Do not close this on the child count: three children are closed and five of its own six criteria are not
All three native sub-issues are CLOSED, which is the first thing a reader checks. Five of the six criteria in this body are unticked, and none of them is delegated to a child — they are this umbrella's own work:
source_stateon the fact.DOCUMENTentity from Umbrella: knowledge/memory is the relation system of record and every module a consumer — today nothing outside knowledge calls it #17526 carries one of two locator kinds — an external URI for sources we do not copy, or an internal object key for retained copies.The sixth (settle the deployment fact: central
HttpClientor per-nodePersistentClient) is ticked, and it is the one that was readable only from the running system.So the child count and the criteria disagree, and the criteria are the closure argument. Closing on "3/3 children closed" would close five criteria nobody has done — including the storage decision, which is the largest single piece of work named here.
Recorded during a read-only audit of the 13 open umbrellas in
v0.9-umbrellas; nothing here was ticked or changed.