The storage layer carries three concerns:
- Canonical markdown — the source of truth; everything else is derivable from it.
- The single per-tenant DuckDB store —
relational metadata + the typed link graph + block-level
retrieval (HNSW via the
vssextension, BM25 via theftsextension on the sameblockstable) + the Loro CRDT op log + the frontmatter index. - External attached data — read-only DuckLake catalogs
mounted as
external.ducklakefor the origin axis, via DuckDB'sATTACHmechanism. (Spec concept, not yet built — do not confuse with the DuckLake storage backend below, which is a different, shipped feature: escurel's own derived index living in DuckLake, not a foreign read-only source.)
All three sit behind one trait, LaneStore, so the
filesystem-backed default and the S3-backed variant share the
upper layers verbatim. The Rust port preserves the 28 end-to-end
assertions and the spike outcomes T1–T6 from the Python prototype
as the acceptance baseline for indexer behaviour.
${ESCUREL_DATA_DIR}/tenants/<tenant_id>/
├── manifest.toml # tenant metadata, quotas, embedding provider
├── markdown/ # canonical source
│ ├── skills/
│ │ ├── customer.md
│ │ ├── meeting.md # event-typed skill
│ │ └── escurel.md # the mandatory meta-skill
│ └── instances/
│ ├── customer/
│ │ └── acme-corp.md
│ └── meeting/ # event instances live here, not in a separate "events/"
│ └── 2026-04-12-acme-qbr.md
├── escurel.duckdb # single DuckDB file: pages, links, blocks
│ # (with vss + fts indexes), crdt_ops,
│ # crdt_snapshots,
│ # external_credentials (sql_view secrets)
├── blobs/ # document-backend canonical originals (sha256-keyed)
│ ├── inbox/ # deposited-but-not-yet-processed uploads
│ └── <sha256> # ingested originals (PDF/DOCX/…); chunks derive from these
├── external/ # DuckLake / Iceberg / Delta catalog mount-points
│ └── ducklake.config # ATTACH parameters per attached catalog
└── cache/
├── embeddings/ # warm cache for re-embed parallel
└── compacted/ # staging for `compact_lanes`
This layout is the export format: tenant_export produces a
deterministic tarball of the above directory minus cache/. The blobs/
subtree is part of the canonical corpus (a document instance's chunks are
re-derivable from its blob, but the blob itself is not), so it is included in
the export. The external_credentials table holds sql_view secrets and is
exported with the DuckDB file. tenant_import is the inverse.
Under the S3 LaneStore the same tree maps to S3 keys via:
s3://<bucket>/<prefix>/tenants/<tenant_id>/
├── manifest.toml
├── markdown/skills/customer.md
├── markdown/instances/customer/acme-corp.md
├── escurel.duckdb
└── external/ducklake.config
The cache/ subtree is per-node and never lives on S3 (model
weights cache and embedding-recompute staging are local).
Spool (spool/<tenant_id>/) is similarly local and never
synced — see the S3-backend-unavailable row in the
crash-recovery summary.
#[async_trait]
pub trait LaneStore: Send + Sync + 'static {
/// Plain byte read; used for markdown files and small artefacts.
async fn read(&self, key: &Key) -> Result<Bytes>;
/// Atomic write-then-publish. Returns the new content version.
async fn write(&self, key: &Key, body: Bytes) -> Result<Version>;
/// Enumerate keys under a prefix. Used by audit + tenant_export.
async fn list(&self, prefix: &Key) -> Result<Vec<Key>>;
/// Used by `compact_lanes` and tenant_delete.
async fn delete(&self, key: &Key) -> Result<()>;
/// Object-store URL form, suitable for handing to DuckDB
/// (`httpfs`) without copying through this process.
fn url(&self, key: &Key) -> Result<Url>;
// Content-addressed blobs for the Document/RAG backend ride on the
// primitives above (see §"Instance backends" below):
// put_blob(tenant, body, max_bytes) -> BlobId, put_inbox_blob,
// get_blob, get_inbox_blob, promote_inbox_blob, delete_blob,
// list_blobs.
}
// **Not yet implemented.** A streaming `open_writer(key) ->
// AsyncWrite` handle (a `tokio::fs::File` for FS, a drop-flushing
// multipart writer for S3) is intentionally absent from the trait
// until a caller actually needs it; today all writes go through the
// buffered `write`.Key is a tenant-scoped relative path
(Key::new(tenant_id, "markdown/skills/customer.md")); the
implementation maps it to a filesystem path or an S3 object
key.
Three implementations ship:
-
FsStore.${ESCUREL_DATA_DIR}/tenants/<tenant>/<rest>. Writes go to<rest>.tmpandrename(2)to publish (atomic on POSIX same-filesystem).url()returnsfile://.... -
S3Store. Backed by the officialaws-sdk-s3crate (notobject_store), with an explicit endpoint override, static credentials and forced path-style addressing — no ambient AWS credential chain is consulted. Keys are S3 paths under a configured prefix.url()returnss3://..., consumed by DuckDB'shttpfsextension when reading or by DuckLake when an external catalog is attached. Writes are a single atomicPutObject(S3 is atomic at the object level, so no temp-then-rename dance is needed and no multipart is used); the returnedVersionis the object's version-id, falling back to its etag.delete()HEADs first because S3 DeleteObject is idempotent and would otherwise not honour the trait'sNotFoundcontract. The S3 backend assumes only basic S3 semantics (GET/PUT/DELETE/list/multipart-upload, path-style addressing, no STS, no presigned URLs, no AWS-S3-Tables-or-Vectors specific APIs). Verified backends: AWS S3, MinIO, Hetzner Object Storage. Critical: the S3 endpoint hostname returned byS3Store::url()must equal the hostname the LaneStore is configured against — DuckDBhttpfshonours the secret'sENDPOINTfield literally, so a hostname mismatch between the LaneStore config and the DuckDB ATTACH/secret produces silent unsigned PUTs and 403s on writes. On cattle nodes,/etc/hostsrewrites are not an acceptable workaround; configure the LaneStore against the object-store hostname directly. -
GcsStore. Backed by the officialgoogle-cloud-storagecrate. Same object layout asS3Store({prefix}/tenants/{tenant}/{path}), so one bucket is readable by either backend and a migration is a copy rather than a rewrite.url()returnsgs://.... Credentials are Application Default Credentials: the metadata server / workload identity on GCP, or a service-account key file (ESCUREL_STORAGE_GCS_CREDENTIALS_PATH) off it. Writes are a single atomic object write; theVersionis the object's generation. Unlike S3,delete()on a missing key is already a 404, so no HEAD-first dance is needed.Residency note: the DataZoo substrate's SPEC §5 keeps app/customer data on Hetzner Object Storage and only recovery/integrity-critical data on GCP, so
GcsStoreis a portability backend for GCP-hosted deployments — substrate tenant lanes must not be pointed at it.
DuckDB happily operates against an object-store URL via
httpfs; we do not need to round-trip data through the
server process. The S3 and GCS backends are gated behind the
s3 and gcs Cargo features respectively; the published
container image and the release binaries build both.
Implement the five required LaneStore methods (read, write,
list, delete, url) and override backend() and size();
the blob layer rides on the defaults. Then run the shared
contract — crates/escurel-storage/tests/conformance/ —
against it. Backend-specific behaviour (URL scheme, client
construction, atomicity mechanism) stays in the per-backend test
file; everything the trait owes any backend belongs in the shared
suite so the implementations cannot drift apart.
The indexer is the Rust port of the Python prototype's
~430-LOC script. It has two entry points — update_page
(steady state) and rebuild (recovery).
Per-tenant write lock held throughout. Steps:
- Parse markdown. Frontmatter, body, blocks (
^blk-...anchors auto-synthesised if absent). - Parse wikilinks using the regex-plus-code-region-stripping
parser (do not use a markdown AST library — they
fragment text on
[). Wikilinks are extracted from the body and from frontmatter field values (e.g.about:,derived_from:,primary_sponsor:); a frontmatter value that YAML parses as a nested flow sequence (about: [[skill::id]]) is rendered back to its raw[[…]]markup before parsing. Frontmatter links carry their originating field inlinks.src_field(frontmatter.<key>); body links leave itNULL. This makes a relationship an instance declares only in frontmatter (e.g. an event whoseabout:points at its entity) reachable vianeighbours. - Validate (the four index-time checks plus
required-frontmatter check; events get the extra check that
at:parses as RFC 3339). - Embed new/changed blocks (candle EmbeddingGemma).
- One DuckDB transaction. Upsert into
pages; delete + insert intolinks(withlink_skillandlink_versionpopulated); delete + insert intoblocksfor changed blocks (populatingdense_vec,body, denormalisedskill/page_type/at_ts); insert any newcrdt_opsrows from a live session that closed in this call; refresh thevssHNSW index for the affected rows (PRAGMA hnsw_compact_indexor per-row update — see "VSS index maintenance" below); refresh theftsindex (PRAGMA refresh_fts_index('blocks')per the FTS extension semantics). Commit. - Publish the markdown file via
LaneStore::write— write-then-rename, so the markdown file appears only after the DuckDB commit succeeds. On commit failure the markdown stays at the previous version. - Emit issues (warnings) and return.
Per-tenant write lock held throughout. Drops the affected
rows for scope (one page, one skill's instances or the
whole tenant) inside a DuckDB transaction and runs
update_page for every markdown file in canonical order.
Cost on the prototype was approximately 32 ms per page; the
single-store path is at least as fast because the write is
one DuckDB transaction. Streams progress events to the admin
client.
Read-only. Per-tenant read lock; concurrent reads are fine.
Compares two sets: markdown files on disk and page rows in
DuckDB. Returns the two asymmetric differences as the audit
admin response. audit also reconciles the external backends:
managed vw_ views ⟂ sql_view backend_refs (no orphan views) and
blobs/ ⟂ document backend_refs / inbox ⟂ ingest Events (no orphan
blobs).
The InstanceBackend seam (markdown |
sql_view | document) is a BackendRegistry keyed by skill id on
AppState (next to the Indexer, not on it — MarkdownBackend holds an
Arc<Indexer>, so putting the registry on the indexer would cycle). Each
backend holds an Arc<Indexer> and delegates; the indexer's read/search/write
methods stay put. Markdown is bit-identical to pre-feature behaviour.
sql_view. create_instance runs under the per-tenant write lock:
INSTALL/LOAD the connector, ATTACH … (READ_ONLY) (the engine rejects
write-back — no app-level enforcement needed), CREATE VIEW vw_<deterministic>,
then a SELECT … LIMIT 0 to capture a source_schema_fingerprint stored in the
overlay's backend_ref. Secrets resolve from the external_credentials table
(DuckDB CREATE SECRET), never from markdown. rebuild re-ATTACHes +
re-CREATE VIEWs from each backend_ref; validate_bindings re-probes and
fingerprint-compares, marking drift binding_degraded (reads fail closed).
Only vw_-prefixed managed views are ever projected, and source identifiers /
filter fragments pass injection guards. ERPL is unsigned, so its connection is
opened with allow_unsigned_extensions only behind an admin opt-in.
document. LaneStore gains a content-addressed area —
put_blob(tenant, body, max_bytes) -> BlobId(sha256), put_inbox_blob,
get_blob, get_inbox_blob, promote_inbox_blob, delete_blob,
list_blobs (default-impl'd on the trait over read/write/list, so FsStore
- S3 get them for free) — backing
blobs/andblobs/inbox/.max_bytesis the per-blob size quota (oversize bodies are rejected before any write);delete_blobis used byrebuildto reclaim orphan blobs. Ingestion is two-phase to keep the write lock short: theDocumentIngestWorkerextracts off the lock (anExtractor:PlainTextExtractorfortext/*;KreuzbergExtractorfor PDF/DOCX/PPTX/XLSX, compiled in by default — see platform.md), then materialises under a brief lock — chunks become Nblocksrows under onepage_id, embedded via theEmbedder, structurally identical to a markdown page-with-blocks for retrieval/ACL. ADocumentProcessorseam lets a future LLM-driven processor swap in without touching intake/materialise (v1 is deterministic).rebuildre-extracts → re-chunks → re-embeds from the retained blob; the FTS reindex is debounced/batched (dense HNSW is live on append, only BM25 lags).
Shipped feature (ADR-0009,
design summary docs/notes/2026-07-18-ducklake-architecture.md) —
distinct from the read-only external.ducklake origin-axis concept above,
which remains unimplemented. This backend lets the derived index (the
"single per-tenant DuckDB store" described earlier in this doc) run as
stateless read replicas sharing one corpus, instead of one single-writer
DuckDB file.
Selected via ESCUREL_INDEX_BACKEND=single-file (default, today's
behaviour, byte-identical) or ducklake. When ducklake,
ESCUREL_ROLE=writer (default) keeps a local single-file DuckDB as before
and additionally attaches a DuckLake catalog — Postgres for metadata
(ESCUREL_DUCKLAKE_CATALOG_DSN), Parquet on GCS/S3/local dir for data
(ESCUREL_DUCKLAKE_DATA_PATH) — to publish snapshots on demand or on a
timer. ESCUREL_ROLE=reader has no local DuckDB file: it adopts the
latest published snapshot into an in-memory DuckDB at boot and polls for
new ones every ESCUREL_SNAPSHOT_REFRESH_SECS (default 30s), hot-swapping
the serving Indexer with no restart and no torn in-flight requests.
ESCUREL_ROLE=writer is single-instance, and enforced. Two writers
against one catalog each publish their own snapshot of the whole lake and
prune parquet the other just committed — acknowledged writes silently
disappear over the following minutes (#371). A writer therefore takes a
single-writer lease at boot: a Postgres advisory lock on the catalog
database, held on a dedicated session for the process lifetime. A second
writer finding it held fails its boot loudly; the lock releases with the
holder's session (clean stop or crash alike), so the Kamal STOP-FIRST
redeploy hands over without operator action. ESCUREL_WRITER_LEASE=off
disables the guard for operators who guarantee a single writer some other
way (the lease client speaks plain TCP, so a TLS-only catalog needs
this). HA is one writer + N readers, never N writers.
pages/links/blocks (+ the group_members/external_endpoints/
pack_subscriptions registries) are the shared corpus mirrored into the
lake; external_credentials never leaves the writer. Three per-user data
classes that need strong consistency instead of eventual — chat_messages,
events, and crdt_ops/crdt_snapshots — are re-homed to shared Postgres
tables in the same Cloud SQL database (chat_pg/events_pg/crdt_pg),
attached read-write from every replica including readers; see the
design note for the full per-table breakdown and the CRDT scope boundary
(durable storage is shared across replicas; live cross-replica session
failover is not).
chat_messages and events can each be pointed at a table in the lake
rather than the catalog's database — ESCUREL_CHAT_BACKEND /
ESCUREL_EVENTS_BACKEND = postgres (default) | ducklake, set
independently. The surface, row shape, ordering ((ts, msg_id) with ULID
tiebreakers) and cursor format are identical; only where the payload
physically lives changes. That matters when the catalog and the object
store fall under different data-residency rules: on the lake, the payload
follows DATA_PATH.
crdt_ops/crdt_snapshots have no lake variant and stay on Postgres.
Three properties the Postgres tables get from the schema, and how the lake
variant keeps them (all measured — see
docs/notes/discovered/2026-07-25-ducklake-multiwriter-and-compaction.md):
- Every replica writes. The lake is attached a SECOND time, read-write,
under its own alias, alongside the corpus attach — which stays
READ_ONLYon a reader. Concurrent read-write attaches from independent processes lose no writes. So readers still serveappend_message/capture_event, exactly as with the Postgres variant. - Cross-replica read-your-writes. Appends commit per call, so a separate replica sees a row immediately — no publish/adopt cycle. This is why appends are NOT batched into a flush window: batching would have cut the file count but delayed visibility by the flush interval, breaking the property the surface exists to provide.
capture_eventidempotency. DuckLake enforces no PRIMARY KEY and has noON CONFLICT, so first-writer-wins is enforced by an anti-join in the INSERT instead. Note this is weaker than a PK: two simultaneous captures of one id could both insert. The runner's own SQLite ledger is what makes exactly-one-run-per-event a hard invariant.
The cost, stated plainly. Data inlining must stay off (inlined rows
live in the catalog, which defeats the point), so every append writes its
own Parquet object. ducklake's own compaction calls
(ducklake_merge_adjacent_files, ducklake_rewrite_data_files, both
overloads) are silent no-ops on this shape. What works is the pattern
publish_lake already uses for the corpus — CREATE OR REPLACE TABLE t AS SELECT * FROM t — followed by snapshot expiry and cleanup, which is what
actually frees the superseded objects. Measured: 40 files → 41 after the
rewrite → 1 after GC.
The publish task runs that pair periodically. Because it is what bounds
object growth, a lake-backed surface makes the task non-optional: when
ESCUREL_SNAPSHOT_PUBLISH_SECS is unset it defaults to 300s instead of
staying disabled. An explicit 0 still disables it — the operator's
retention math (ESCUREL_SNAPSHOT_KEEP × interval) is never overridden
silently; the server logs a warning that appends will not be compacted.
Volume ceiling. Object count between compactions is
(compaction interval × append rate); at the documented 120 writes/min
per-tenant quota and the 300s default, that is ≤ ~600 objects. Compaction
itself is O(rows) — it rewrites the whole table — so retention
(delete_chat_history(before_ts)) is what bounds compaction cost, and the
interval is the knob trading object count against rewrite volume. A
deployment with a chat firehose, or one that never prunes history, should
stay on the Postgres variant: the spec's answer for multi-million-row event
volume is still an external read-only lake attached for the origin axis,
not this operational table.
-- Pages: one row per markdown file.
CREATE TABLE pages (
page_id VARCHAR PRIMARY KEY, -- ULID
slug VARCHAR, -- mutable, indexed but not unique
skill VARCHAR NOT NULL,
page_type VARCHAR NOT NULL, -- 'skill' | 'instance'
frontmatter JSON NOT NULL,
body_hash VARCHAR NOT NULL, -- WHAT changed (audit); see last_written_by for WHO
at_ts TIMESTAMP, -- mirrored from frontmatter.at (NULL for non-events)
last_written_by VARCHAR, -- server-stamped principal of the LAST write (#357/CR-6);
-- the verified token subject, never a caller-supplied
-- field. NULL for pages last written before the column
-- existed. Read back via `expand.page.last_written_by`.
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE INDEX pages_slug ON pages(slug);
CREATE INDEX pages_skill ON pages(skill);
CREATE INDEX pages_skill_at ON pages(skill, at_ts); -- event-log scan support
-- Links: one row per wikilink occurrence.
CREATE TABLE links (
src_page VARCHAR NOT NULL,
src_anchor VARCHAR,
src_field VARCHAR, -- `frontmatter.<key>` if the link came from a frontmatter value; NULL for body links
dst_page VARCHAR NOT NULL,
dst_anchor VARCHAR,
link_skill VARCHAR NOT NULL, -- the skill segment of the typed link
link_version VARCHAR, -- the @version segment (NULL if unpinned)
-- dst_anchor is NOT NULL DEFAULT '' in the live schema: DuckDB
-- primary keys forbid NULL columns, so "no anchor" is stored as ''
-- and readers project it back to NULL.
PRIMARY KEY (src_page, src_anchor, dst_page, dst_anchor, link_skill)
);
CREATE INDEX links_dst_skill ON links(dst_page, link_skill); -- backlinks
CREATE INDEX links_src_skill ON links(src_page, link_skill); -- forward links
-- Blocks: one row per markdown block.
CREATE TABLE blocks (
block_id VARCHAR PRIMARY KEY, -- "<page_id>:<anchor>"
page_id VARCHAR NOT NULL,
anchor VARCHAR,
ordinal INT,
body VARCHAR NOT NULL, -- the block's markdown text, VERBATIM (display + provenance)
context VARCHAR, -- structural situating prefix "[title › headings › p.N]" (GH #216);
-- concatenated with body only at embed/FTS/rerank time; NULL for
-- ordinary page blocks and contextualize=off
dense_vec FLOAT[768], -- EmbeddingGemma default; vss HNSW-indexed; embeds context+"\n"+body
-- denormalised for filtered retrieval (single-SQL push-down):
skill VARCHAR,
page_type VARCHAR,
at_ts TIMESTAMP -- mirrored from frontmatter.at if present
);
CREATE INDEX blocks_page ON blocks(page_id);
CREATE INDEX blocks_skill ON blocks(skill);
CREATE INDEX blocks_at ON blocks(at_ts);
-- vss + fts indexes are created via extension DDL after the table:
INSTALL vss; LOAD vss;
CREATE INDEX hnsw_blocks_vec ON blocks USING HNSW (dense_vec)
WITH (metric = 'cosine', ef_construction = 128, ef_search = 64, M = 16);
INSTALL fts; LOAD fts;
PRAGMA create_fts_index('blocks', 'block_id', 'body', 'context',
stemmer = 'porter', stopwords = 'english',
ignore = '(\.|[^a-z])+', lower = 1);Beyond the tables above, later migrations add a few additive tables
this section does not detail: chat_messages, group_members,
external_credentials, and external_endpoints.
pages.last_written_by and crdt_ops.principal (escurel#357 / CR-6) are
server-owned: the gateway writes the subject it verified on the call and
ignores every caller-supplied field of any name, exactly as capture_event
does for events.provenance.captured_by. A caller cannot assert its own
authorship on any of the three.
Both columns are NULLable, deliberately. They are added to already-
populated tables via ADD COLUMN IF NOT EXISTS, which NOT NULL forbids;
and a NOT NULL DEFAULT '<sentinel>' would attribute every historical row
to a principal that did not write it. NULL means "written before the gateway
recorded who", which is the truth.
last_written_by is last-writer-wins, like updated_at — it is not a
history. It survives rebuild, which re-derives pages from the markdown
lane: the lane stores content and no principal, so the rebuild carries the
recorded writer across the truncate rather than blanking every page. A
per-write history (the issue's "fuller" shape — a separate write-audit
table) is not built.
The at_ts column on pages plus the pages_skill_at
composite index is the event-log scan support:
list_instances('meeting', filter={at: {">=": "2026-04-01"}}, order_by='at desc') becomes SELECT * FROM pages WHERE skill='meeting' AND at_ts >= '2026-04-01' ORDER BY at_ts DESC LIMIT 50 — index-served, sub-millisecond even at 100 k event
instances per skill.
The denormalised skill, page_type and at_ts columns on
blocks make filtered vector search a single SQL statement:
a query of the form "return the top-10 closest blocks to vector V
whose skill is meeting and whose at_ts is at least 2026-04-01"
expresses as one SQL with a vss_search() call against dense_vec
joined with the relational predicates.
Every other filterable frontmatter key (status, tier, risk,
etc.) is matched directly over the canonical pages.frontmatter
JSON — list_instances filters with
json_extract_string(frontmatter, '$.<key>') = ? rather than
consulting a separate index. This needs no schema migration when a
new skill adds a new field, at the cost of a scan over the
skill-filtered pages rows rather than an index seek.
The Loro engine — the in-memory adapter and LiveDoc actor —
persists into two DuckDB tables that share the per-tenant store
and the per-write transaction:
CREATE TABLE crdt_ops (
page_id VARCHAR NOT NULL,
op_id VARCHAR NOT NULL, -- Loro op id (HLC-ordered)
hlc BIGINT NOT NULL, -- the HLC value for monotonic sort
parent_op_id VARCHAR, -- chain parent (NULL for genesis)
op_bytes BLOB NOT NULL, -- raw Loro op
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
principal VARCHAR, -- server-stamped author of THIS op (#357/CR-6).
-- NOT the Loro peer id inside op_bytes: a peer id
-- identifies a device, so two people editing from one
-- browser tab share it. Read back via `list_op_authors`.
PRIMARY KEY (page_id, op_id)
);
CREATE INDEX crdt_ops_page_hlc ON crdt_ops(page_id, hlc);
CREATE TABLE crdt_snapshots (
page_id VARCHAR NOT NULL,
snapshot_hlc BIGINT NOT NULL, -- the HLC at which the snapshot was taken
snapshot_bytes BLOB NOT NULL, -- raw Loro export_snapshot
taken_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (page_id, snapshot_hlc)
);
-- events: the global inbox / event store (M7 — Event-sourcing surface).
-- An event is the dynamic input; `label_skill` links to the SKILL that
-- knows how to process it, `instance_page_id` to the INSTANCE it belongs
-- to once an (external) agent has processed it. `status='inbox'` until
-- assigned. Events are NOT pages and are not in the `links` graph; their
-- surface is capture_event / list_inbox / list_events / assign_event.
CREATE TABLE events (
event_id VARCHAR PRIMARY KEY,
at_ts TIMESTAMP, -- event time (`at` is a DuckDB keyword)
source VARCHAR NOT NULL DEFAULT '', -- ingest source, e.g. gmail / meet
mime VARCHAR NOT NULL DEFAULT '', -- content type, e.g. message/rfc822
label_skill VARCHAR NOT NULL DEFAULT '', -- skill id: how to process this event type
instance_page_id VARCHAR, -- assigned instance (NULL = inbox)
status VARCHAR NOT NULL DEFAULT 'inbox', -- 'inbox' | 'processed'
title VARCHAR NOT NULL DEFAULT '',
body VARCHAR NOT NULL DEFAULT '',
provenance JSON,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX events_status_at ON events(status, at_ts); -- the inbox view
CREATE INDEX events_instance_at ON events(instance_page_id, at_ts);-- an instance's event historyDemo seeding (M7). seed_from_dir (the ESCUREL_SEED_DIR
bootstrap) loads, alongside the markdown pages, two optional files in
the seed dir: events.json (an array of events captured into the inbox;
an entry with status: "processed" and an instance is also assigned
to that instance's event history) and history.json (an array of
{page_id, states:[{taken_at, markdown}]} CRDT snapshot timelines). Both
are idempotent (skipped if events/snapshots already exist), so the
demo's event stream + per-instance state history is real backend data,
not a frontend fixture.
The LiveDoc actor for a page is one Tokio task that accepts
ops from the open session(s), feeds them to the in-memory
Loro engine and on each accepted op runs a write transaction
that inserts the op row. The transaction is small (one row,
no index rebuild) and commits in under a millisecond on the
local FS backend.
Snapshots are taken on session close (commit=true) and
periodically during long-lived sessions (default every 256
ops or 60 s). After a snapshot inserts into crdt_snapshots,
the ops with hlc <= snapshot.snapshot_hlc for the same page
are eligible for compaction (see "Compaction" below).
Query-time historical state (M7). Beyond crash recovery,
crdt_snapshots is also a read-time API: expand(page_id, as_of = T) loads the snapshot with the greatest taken_at <= T, materializes its "body" text container back to markdown
(escurel_crdt::body_from_snapshot), and re-parses it —
returning the instance's frontmatter+body as it was at T
(the projection of its events up to T). A page with no snapshot
at-or-before T falls through to the current-state path (the
at_ts birth filter), so this is additive. Snapshot histories
can be authored server-side via Indexer::seed_snapshot_history
(deterministic Loro export, escurel_crdt::snapshot_bytes_from_markdown)
— how the demo gives an instance a real state-over-time history.
On server restart, opening a page that has CRDT rows replays
the latest crdt_snapshots row for the page, then any
crdt_ops rows with hlc > snapshot_hlc, yielding the exact
state at crash. If the markdown file on disk is newer than
the snapshot's reflected state (external edit happened), the
two-stage reconciler runs: snapshot wins for cited instances;
external edit wins for new pages and uncited pages.
CRDT state is not part of tenant_export — the canonical
markdown is. On import into a different server, all live
sessions reset to the markdown head. This is by design: CRDT
state is a runtime concern, not a corpus artefact.
The single DuckDB store clears 100 k instances at typed-backlink
5.21 ms p95 and extrapolates to 1 M at ~13 ms p95 for the
backlink path. The vector + skill-filter path is targeted at
≤ 50 ms p95 at 100 k, pending the pre-deployment gate in
../adr/0001-duckdb-only-storage.md.
Above ~1 M events — multi-million-event corpora such as a
chat-message firehose or a CDC stream of CRM activity — the
recommended path is:
- Author one
[[table::messages]]skill instance whose body describes the schema and thecatalog/schema/namefields. - Land the events in an external DuckLake (or Iceberg) table.
- Attach the catalog read-only via
attach_external. - Author one or more
[[query::*]]instances that wrap typical timeline queries (messages-for-customer,recent-incidents-affecting-project). - Agents reach individual events through
run_stored_queryrather than throughlist_instances.
The origin-axis path is the volume escape hatch for events. Markdown is reserved for the events that have curation or authoring value (meetings, key decisions, postmortems); bulk event streams stay where they came from. Both look identical to the agent — the dispatcher hides the lane.
The HNSW index on blocks.dense_vec is built with INSTALL vss; LOAD vss; plus CREATE INDEX … USING HNSW after the
table is populated. The extension's current update semantics
expect the writer to refresh the index after batched
modifications; the indexer issues PRAGMA hnsw_compact_index
at the end of every write transaction that touched blocks.
Cost characterisation is the load-bearing item of
../adr/0001-duckdb-only-storage.md's
pre-deployment gate.
The HNSW index lives inside the DuckDB file alongside the
blocks table. Opening an intact DuckDB file via
DuckDB.Open() loads the existing index as-is; the index is
not reconstructed per open. After a crash mid-write the
DuckDB transaction is rolled back (no partial index state),
so on the next open the index is consistent with the
committed blocks rows without further work.
If the DuckDB file is missing but the canonical markdown is
intact on the LaneStore — the cattle-node-loss case, where a
host was recreated and the data Volume reattaches to a survivor
with no local DuckDB state — the server detects the
missing file on first tenant access and runs rebuild(tenant)
automatically before serving the first request. Cost ~32 ms/page:
a 1000-page tenant rebuilds in ~32 s, a 10 000-page
tenant in ~5 min. Transparent to agent callers except for the
first-request latency on that tenant.
This recovery property is what makes the per-tenant
escurel.duckdb file cattle rather than pet: canonical
markdown on the LaneStore is the source of truth, the DuckDB
file (including the HNSW and FTS indexes) is a rebuildable
derivative.
DuckDB compaction is implicit (CHECKPOINT runs after the
write transaction completes); file rewrites happen during the
regular write path. A compact_lanes admin endpoint forces a
CHECKPOINT plus a VACUUM plus a PRAGMA hnsw_compact_index for any tenant whose store size grows
above a configurable watermark. The crdt_ops table also
benefits from periodic truncation: ops older than the most
recent crdt_snapshots row for the same page are eligible
for deletion, controlled by crdt.ops.retain_post_snapshot
(default keep the most recent 1024 ops per page for one-step
replay margin).
For S3 backends, compaction also coalesces many small DuckDB checkpoint files into fewer larger ones (vendored behaviour).
| failure | recovery |
|---|---|
| Process killed mid-write | DuckDB rolls back the transaction; pages, links, blocks (with vss/fts updates), crdt_ops all revert together; markdown file is left at the previous version because the rename happens only after commit |
| Process killed after DuckDB commit, before markdown rename | audit reports markdown_not_in_duckdb for the new page (DuckDB has it, markdown does not yet); rebuild reconciles by re-writing the markdown from the DuckDB row, or admin can re-run update_page from a re-submitted source |
| Markdown rename(2) crashed mid-write | The pre-existing markdown is intact (.tmp orphans cleaned on startup) |
| External edit mid-session (live mode) | Two-stage reconciler: for cited pages the CRDT snapshot wins; for new or uncited pages the external edit wins |
| DuckDB file corruption (rare) | Auto-suspend tenant (status: suspended_corrupt); admin runs rebuild --tenant <id> to recreate from canonical markdown |
vss or fts index corruption |
PRAGMA drop_index plus rebuild — the index is derivable from blocks.dense_vec and blocks.body without re-embedding |
| S3 backend timeout | Local spool under ${ESCUREL_DATA_DIR}/spool/<tenant>/ — host-local, not synced to the LaneStore; queue flushes on reconnect. On a host recreate the previous host's spool is lost; the markdown source-of-truth is preserved (writes only enter the spool after a successful DuckDB commit per the row above), so recovery is a client re-submit |
Cattle node destroyed; escurel.duckdb gone; markdown intact on LaneStore |
First request to the tenant triggers automatic rebuild from canonical markdown on the LaneStore (~32 ms/page; ~32 s for 1000 pages); transparent to agent except for one-time first-request latency |
The two recovery primitives (audit, rebuild) are the full
playbook. Operators do not need to know the internal storage
mechanics to recover, and the playbook is bounded because there
is one index to reconcile.