ADR 0015: OpenDAL data seam — knowledge reads from, and persists to, any data backend
- Status: accepted
- Date: 2026-10-01
- Supersedes: nothing (extends the
vector_dbs[].sources[].pathandvector_dbs[].persistconstructs, K1; the runtime-source registry, ADR 0014)
Context
The knowledge engine reads sources exclusively from the local filesystem
(std::fs walk + read) and persists state exclusively to local JSON files
(the index via Store::save/load, the ADR 0014 runtime registry next to it).
The requirement: support OpenDAL in mosaic, to be able to read and write
data from different data sources/targets — concretely for the knowledge
feature: a book library that lives on object storage must be addable as a
source (declared or at runtime, ADR 0014), and the knowledge state (index +
runtime registry) must be writable to a target beyond the local disk
(object storage that survives container replacement, in-RAM for tests).
Apache OpenDAL is the accepted data-access layer for exactly this: one pure-Rust API over many backends (fs, memory, s3, gcs, azblob, oss, obs, cos, hdfs, …), services feature-gated.
Three constraints shape the design:
- The engine is sync, and it is called from mixed contexts.
ingest_source, persist I/O and the registry I/O run at boot (inside the host app's async runtime) and intokio::task::spawn_blockingworkers (no runtime context at all). OpenDAL's syncBlockingOperatormust be constructed inside a runtime context (it captures the currentHandle) — which thespawn_blockingcontexts do not have. The seam therefore drives OpenDAL's asyncOperatorfrom the engine's own dedicated tokio runtime (data::runtime(), a small multi-thread runtime created lazily once) viablock_on— valid from every calling context (a separate runtime, so no nested-block_onpanic; blocking the calling thread matches the engine's existing blocking-I/O posture). - The engine stays env-pure (Z7). The workspace bans
std::env::varin the engine/generator crates — so storage credentials are not read by the engine. The generated app (and themosaic knowledge-reportCLI) wire aStorageEnvprovider over their own env reads; the engine resolves credentials through the provider and fails closed with a named error when it has none. - The vendored engine is compiled into the web lens in
full/islands(native SSR) only — never for wasm (csr). A native-only dependency is therefore safe to add to the engine.
Decision
One engine module (a sync seam), two URI upgrades, env-gated credentials,
no new DSL key beyond reusing path/persist as URIs:
-
A new engine module
data— the syncDataBackendtrait.pub trait DataBackend: Send + Sync { /// Direct children of a directory (`""` = the backend root). fn list(&self, dir: &str) -> io::Result<Vec<DataEntry>>; fn read(&self, path: &str) -> io::Result<Vec<u8>>; /// Write bytes (parents as the service supports). fn write(&self, path: &str, data: &[u8]) -> io::Result<()>; fn exists(&self, path: &str) -> bool; fn is_dir(&self, path: &str) -> bool; /// Human-readable location (stats / the /kb page). fn describe(&self) -> String; } pub struct DataEntry { pub name: String, pub is_dir: bool } /// Storage credentials / region / endpoint provider (see §4). pub struct StorageEnv { /* Arc<dyn Fn(mosaic_key, standard_key) -> Option<String>> */ }Two implementations + a factory:
LocalBackend { root: PathBuf }—std::fs. The default: a plain path resolves to this, byte-identical to today's behavior (all existing engine tests stay green unchanged).OpenDalBackend { op: Operator, desc }— any compiled-in OpenDAL service, driven by the engine's dedicated runtime (constraint 1);list= per-directoryop.list(OpenDAL lists the queried directory itself + its direct children — the walker skips the self-entry), recursion stays in the ingest walker.data::backend_for(uri: &str, base: &Path, env: &StorageEnv) -> Result<(Box<dyn DataBackend>, String), String>— the backend is rooted at the URI's parent, the returnedreladdresses the URI's last segment (""= the whole root): sources walk fromrel, the persist target reads/writesreldirectly.- no scheme (or
file://) →LocalBackendrooted atbase(relative) or the file's parent (absolute); s3://bucket/…,memory://…,fs://host/…→OpenDalBackend; any other scheme is a named error (data: schemexis not available in this build); a service that cannot be configured (missing credentials/region) is a named error, fail-closed (the caller wanted a verdict).
- no scheme (or
-
Sources can be data URIs (read from any target).
sources[].pathaccepts an OpenDAL URI (s3://bucket/books/,memory://lib,fs://host/lib) alongside a plain path and agitsource (unchanged — git stays on the git-CLI seam). Ingestion resolves the source's backend viabackend_forand walks withlist/readinstead of the fs walk. Locators/meta for a URI source carry<label>/<rel-from-source-root>— the K8 git-label pattern:labelis the slugifiedscheme-authority-pathof the URI (deterministic, independent of any cache location); entry ids are the flat slug of that path (the existing id convention). A single-file URI (s3://bucket/book.epub) ingests as one document; include/exclude filters are unchanged (substring match on the rel path). Remote PDFs are staged to a temp file forpdf-extract(it takes an fs path) and cleaned up after. ADR 0014'ssource_labelfor a URI source is that label — the runtime add/remove flow (K9) works identically for remote data: the/knowledgeform'spathinput accepts URIs, and the fail-closed "path not found" check becomes!backend.exists(root)(the engine performs it; the app's fs pre-check skips URIs). -
The persist target can be a data URI (write to any target).
vector_dbs[].persist(existing key, today a local path) accepts an OpenDAL URI (e.g.s3://bucket/kb.json). The engine gains byte-levelStore::serialize() -> Vec<u8>/Store::deserialize(&[u8]) -> Store(the existingPersistedshape);save(Path)/load(Path)remain asLocalBackendshims so engine tests and the CLI are unchanged. The generated app resolves the persist backend from the URI and reads/writes the index and the ADR 0014 runtime registry through it; the registry sits next to the index — local:knowledge/.runtime/sources.json(unchanged), URI target:<target-dir>/.runtime/sources.json. -
Env-gated credentials through a
StorageEnvprovider — the DSL declares placement, env declares the provider (the established pattern; secrets never intessera.yaml):MOAIC_STORAGE_{SCHEME}_{KEY}(e.g.MOAIC_STORAGE_S3_ACCESS_KEY_ID,…_SECRET_ACCESS_KEY,…_REGION,…_ENDPOINT; scheme upper-cased,-→_), falling back to the provider-standard env (AWS_*for s3). Because the engine is env-pure (constraint 2), the env reads live in the generated app (knowledge_storage_env(), a lazily-builtStorageEnv::newclosure overstd::env::var) and in theknowledge-reportCLI; the engine only calls the provider (env.lookup(mosaic_key, standard_key)). s3 requires key + secret + region (fail-closed named errors when absent — the builder validates); no credentials are read for local paths,memory://, orfs://. -
Dependency.
opendal0.59(default-features = false, featuresservices-fs,services-memory,services-s3) plustokio(rt-multi-thread,net,time— the engine's dedicated runtime, constraint 1), in the workspace crate and the vendoredknowledge-engine/Cargo.toml. Pure Rust (rustls http transport; no native linking, no downloads at build time — same posture as the K7ortload-dynamic seam). Additional backends (gcs, azblob, oss, …) are an enable-a-feature + onebuild_operatormatch arm change. -
Fail-soft / fail-closed split (the established posture).
- Boot: an unreachable URI source or persist target → a named error for that source/target; the app boots; the remaining sources/index work (fail-soft, exactly like K8 git sources).
- K9 runtime add of a URI source → fail-closed
400with the named error (the caller wants a verdict). memory://is per-instance RAM: eachbackend_forcall builds a fresh, private in-memory store — it is a zero-config test/dev seam (deterministic: always empty on a fresh process), not a shared or durable target.
Proof
- Engine unit tests (new
datamodule tests +ingesttests, hermetic — no network):memory://round-trip through the seam: write / read / list / exists;- ingest from a data URI (
fs://over a temp dir — the real OpenDAL path, hermetic): directory source (files ingested, locators<label>/<rel>, content asserted) and single-file source; - missing URI roots fail closed (
memory://— always fresh/empty — and a nonexistentfs://dir both →knowledge source not found); Store::serialize/deserializeround-trip (entries + citations; corrupt bytes → empty store);backend_forresolution: plain path → local (unchanged), unknown scheme → named error, s3 without credentials → named error, s3 without region → named error (all before any I/O), s3 with an explicitStorageEnvprovider resolves;- every pre-existing engine test green unchanged (the local default).
- e2e (full-stack): a declared
memory://kb-remotesource onkb— boot is fail-soft (the app is healthy, the other declared sources remain hybrid-searchable), the URI source is listed in/api/vectordb/kb/sources(proving declared-URI plumbing end to end), and a runtimePOST …/sources/addofmemory://does-not-existis fail-closed400 "not found"(proving the K9 URI path deterministically — memory is always fresh/empty, so no cloud is involved). Posture per K7/K8: the seam's happy path is unit-tested hermetically; CI asserts the fail-soft/fail-closed contracts; no live cloud in CI. - Conformance goldens re-pinned (vendored engine + generated persist
plumbing); workspace tests + clippy
-D warnings+ fmt clean.
Consequences
- A mosaic app can read knowledge from and write knowledge state to any OpenDAL backend — a book library on S3 is a one-line source (declared or added at runtime, ADR 0014), and the index + runtime registry can live on a target that outlives the container.
- Zero behavior change by default: every existing plain-path source and
persistresolves toLocalBackend(byte-identical I/O); no new DSL key (path/persistare simply URIs now); no new env for local apps. - The engine gains one small, sync, well-tested seam (a trait + two impls + a factory) — the OOP shape the platform's external seams are moving toward (git CLI, OCR, rerank, ONNX).
- New dependency:
opendal(pure Rust, feature-gated services; native-only lenses compile it — the wasm/CSR web lens never does).