Engrava
Memory frameworkOfficial Sovantica project
Created by Sovantica
Published in sovantica/engrava. Publisher page
12 GitHub stars
What it is
The memory database for AI agents - graph memory, hybrid search, tamper-evident thought/edge journal.
AutoGineer overview, from the brain’s own files
Engrava is an in-process memory database designed for AI agents, built on SQLite. It provides a structured way to store and retrieve agent thoughts using a graph-based model, where thoughts are linked by typed edges. The system supports hybrid search, combining vector similarity, full-text search (FTS5), recency, and priority. It includes built-in mechanisms for memory consolidation (dreaming), memory hygiene (archiving and garbage collection), and a tamper-evident journal for tracking mutations. Engrava operates as a library with no external service dependencies in its core path, allowing agents to maintain persistent, queryable state locally.
How it works
Memory model: Hybrid
AutoGineer overview, from the brain’s own files
Engrava uses a SQLite database to store thoughts and their relationships. It organizes data into thoughts and typed edges, which can be queried using the MindQL language. The system supports hybrid search by combining vector embeddings, keyword search, and metadata signals. It manages memory through a lifecycle that includes archiving and garbage collection, and it can consolidate memories into reflections. The system is extensible via hooks and provides an MCP server for integration with various AI agent frameworks.
| Folder | Role | Files |
|---|---|---|
| .github | agent-config | 16 |
| docs | docs | 37 |
| examples | examples | 13 |
| scripts | scripts | 24 |
| src | source-code | 89 |
| tests | tests | 201 |
| .githooks | folder | 1 |
The agent reads
- CONTRIBUTING.md evidence: CLAUDE.md
Requirements
Runtimes: Python >=3.11
Databases: SQLite
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Google Antigravity, Kiro, OpenCode, Amp, Roo Code, Cline, JetBrains Junie, Devin (ex-Windsurf)
Set it up
This brain runs code automatically: 2 scripts. Review .githooks/commit-msg, Makefile before opening the folder in Claude Code. No step below turns a hook on for you.
.githooks/commit-msg:runs on git commit-msgMakefile:echo "$(BLUE)>> Installing engrava + dev dependencies$(RESET)"; $(PIP) install --upgrade pip setuptools wheel; $(PIP) install -e ".[dev]"; bash scripts/install_hooks.sh; echo "$(GREEN)>> Done. Run 'make check' to verify quality gate.$(RESET)"
- Not scanned by AutoGineerIts dependencies are installed by pip or uv when you run it and were not scanned by AutoGineer (5 declared). https://pypi.org/project/engrava/
Created by Sovantica. Published in sovantica/engrava. Licence: MIT License.
Usage
AutoGineer overview, from the brain’s own files
- Persistent memory for autonomous AI agents
- Personal knowledge base management
- Conversation storage with semantic retrieval
- Research notes with associative linking
- Memory-backed agent development
- Audit-trailed thought and edge tracking
- “Store a new observation about a project requirement.”
- The agent uses the remember() method to persist the thought, which is then indexed for future hybrid search and graph traversal.
- “Retrieve relevant context for a coding task.”
- The agent uses the recall() method, which performs a hybrid search across FTS5, vector, and graph signals to return ranked, contextually relevant thoughts.
- “Query the memory graph for all observations with high priority.”
- The agent executes a MindQL query: FIND thoughts WHERE thought_type = 'OBSERVATION' AND priority = 'P1'.
- “Consolidate old memories to free up space.”
- The agent triggers the DreamingExtension to cluster related thoughts and compute reflections, or uses the memory hygiene loop to archive low-signal thoughts.
Documentation by Sovantica
Engrava
The memory database for AI agents.
A queryable memory graph with bi-temporal valid-time predicates — in an in-process Python library over one SQLite file, with no generative-model call in the core write path.
Image: CI Image: PyPI Image: Python Image: License: MIT
Engrava is a standalone, in-process database for AI agent memory. Built on SQLite, it provides thought CRUD, edge-based knowledge graphs, embedding-based similarity search, full-text search (FTS5/BM25), and a declarative extension system — all in a single package with zero external service dependencies.
Benchmark results are published, and the runs are reproducible from a separate repository. Every
published run is Group A — memory_pipeline_llms: [], no language model anywhere in the memory
layer's built-in path. That is a property of the architecture's default signals and hooks rather
than a measurement, unless a custom Dreaming signal or Memory Hygiene hook is configured to call
one.
Benchmark results — the live table: every published row with the comparability segment it belongs to.
engrava-benchmark — the runner. Clone it and reproduce a result against the package from PyPI. MIT.
Discussions — questions; reproduction questions belong in the Q&A category.
Use Cases
AI agent persistent memory
Personal knowledge base
Conversation storage with semantic search
Research notes with associative linking
Any application that needs a thought-graph with embeddings
Quick Start
Installation
pip install engravaOptional extras:
pip install 'engrava[vec]' # sqlite-vec vector search backend
pip install 'engrava[embeddings-local]' # sentence-transformers embeddings (local model)
pip install 'engrava[embeddings-openai]' # OpenAI-compatible embeddings API
pip install 'engrava[embeddings-ollama]' # Ollama local embeddings server
pip install 'engrava[embeddings-hf]' # HuggingFace Inference API embeddingsDreaming/consolidation and the knowledge graph need no extra — they are part of the base install.
embeddings-localcarries a large first-run download. It pullssentence-transformersandtorch, plus a further, separate model download (~88 MB forall-MiniLM-L6-v2, cached under~/.cache/huggingface/hub) on first use. On Linux, the PyPI build oftorchalso pulls the NVIDIA CUDA libraries as dependencies: several gigabytes, most of it usable only with an NVIDIA GPU. Without one, install the CPU-only build first (see Configuration: No NVIDIA GPU for the exact command and numbers).engrava-mcp[local]is the exact same download, reached through the server package instead of this one. If you do not need semantic search in-process,pip install engravaalone (no extra, no download, no model) already gives you keyword search, the graph, and MindQL — see Configuration → Quick-start profiles for that and the Ollama-backed alternative that keeps the model out of this process entirely.
Basic Usage
Store a memory and search for it in two calls — no IDs to generate, no record to assemble:
import asyncio
import aiosqlite
from engrava import SqliteEngravaCore
async def main() -> None:
# SqliteEngravaCore wraps an open aiosqlite connection.
async with aiosqlite.connect(":memory:") as conn:
conn.row_factory = aiosqlite.Row
store = SqliteEngravaCore(conn)
await store.ensure_schema()
await store.remember("Python is great for AI agents")
await store.remember("SQLite needs no server")
result = await store.recall("what language is good for agents?")
for thought_id, score in result.results:
thought = await store.get_thought(thought_id)
if thought is not None:
print(f"{thought.essence} (score: {score:.3f})")
asyncio.run(main())remember() stores the text as a thought (generating its ID for you) and
returns the stored ThoughtRecord; recall() runs the same hybrid search as
search_hybrid() and returns the ranked results. This store has no
SearchConfig of its own, and it still ranks with the same default weights —
FTS 0.30, vector 0.55, recency 0.10, priority 0.05, graph 0.00
(opt-in) — as a store built with one; see Hybrid
Search.
For full control — setting
priority, thought type, metadata, or the cognitive cycle on a write — build a
ThoughtRecord yourself and call create_thought().
From here, link thoughts with typed edges, query them with MindQL, or run the full ingest → dream → search tour in the Quick Start guide.
Configuration-Driven Setup
from engrava import SqliteEngravaCore
# from_config opens and OWNS the connection — use it as an async context manager.
async with await SqliteEngravaCore.from_config("engrava.yaml") as store:
# The schema is already applied by from_config.
thought = await store.get_thought("some-id")See docs/configuration.md for the full YAML schema.
Upgrading
Automatic schema migration runs on first connection. See the upgrade guide for compatibility notes, backup guidance, and troubleshooting steps.
Features
Thought CRUD
Create, read, update, and archive thoughts with full lifecycle management.
All models are frozen Pydantic objects — mutations happen via evolve().
Edge-Based Knowledge Graph
Link thoughts with typed, weighted edges. Edge types include ASSOCIATED,
DEPENDS_ON, DERIVED_FROM, CONSOLIDATED_FROM (created by dreaming), and
CONTESTED_BY.
Embedding Search
Store embeddings alongside thoughts and search them with the built-in NumPy
cosine backend or the optional sqlite-vec backend (pip install 'engrava[vec]'). Pluggable embedding providers:
| Provider | Extra | Backend |
|---|---|---|
SentenceTransformerProvider | embeddings-local | Local model via sentence-transformers |
OpenAICompatibleProvider | embeddings-openai | Any OpenAI-compatible API |
OllamaProvider | embeddings-ollama | Local Ollama server |
HuggingFaceProvider | embeddings-hf | HuggingFace Inference API |
CallbackProvider | (built-in) | Custom callable |
Full-Text Search (FTS5)
SQLite FTS5 virtual table with BM25 ranking. Hybrid search combines vector similarity, text relevance, recency, priority, and graph connectivity. Signals that cannot run for a query are skipped and the remaining weights are redistributed.
MindQL Query Language
Declarative query language for the thought-graph:
FIND thoughts WHERE thought_type = 'OBSERVATION' AND priority = 'P1' LIMIT 10
COUNT thoughts WHERE lifecycle_status = 'ACTIVE'
SELECT thought_id, essence FROM thought WHERE thought_type = 'BELIEF'Extensible with custom commands through MindQLExecutor or an
ExtensionManifest; the lifecycle hook registry is reserved and is not
consulted by the core executor.
Extension System
Plug into the thought lifecycle via EngravaHooksProtocol. Subclass
DefaultEngravaHooks when you only need selected active methods:
from engrava import DefaultEngravaHooks, ThoughtRecord
class MyHooks(DefaultEngravaHooks):
async def on_store(self, thought: ThoughtRecord) -> ThoughtRecord:
# Observe or enrich the object returned after persistence.
return thought
async def decay_function(
self, thought: ThoughtRecord, elapsed_cycles: int
) -> float:
# Supply a decay multiplier to an enabled Memory Hygiene pass.
return 1.0Core currently invokes on_store, on_retrieve, and decay_function.
on_store runs after the source thought's row is inserted; changing its
return value does not rewrite the persisted row. score_function and
mindql_extension_registry() remain reserved protocol methods and are not
called by core.
Dreaming / Memory Consolidation
Built-in DreamingExtension for periodic memory consolidation — scores
thoughts via its default signals (no LLM calls), promotes high-value
entries, and creates REFLECTION thoughts by clustering semantically
related thoughts and computing centroid embeddings through a deterministic
structural function, not an LLM. A custom signal you register with
DreamingExtension runs whatever code it contains. Available since 0.3.0.
→ See docs/benchmarks.md for reproducible
evidence (synthetic benchmark suite runnable in ~5 minutes).
Forgetting / Memory Hygiene
The subtractive half of memory maintenance, paired with Dreaming: an opt-in
loop whose built-in scoring makes no LLM calls, that archives cold,
low-signal thoughts — the default action, reversible via restore_thought —
and, as a separately opted-in step, garbage-collects them (not reversible)
once both restore windows have elapsed under their non-zero defaults — a
cycle count and a wall-clock duration, either of which can be configured to
0 to disable that window. OFF by default; once enabled, archived thoughts
drop out of default retrieval and can be restored (restore_thought /
include_archived).
→ See docs/memory-hygiene.md for the
loop, protection, restore windows, and the honest deletion posture.
Tamper-Evident Thought/Edge Journal
Opt-in hash-chain journal that records thought and edge mutations (plus
action status/verification_status transitions) as SHA-256-linked, before/after
entries — a tamper-evident thought/edge journal, not a whole-database audit
(embeddings and action creation are not covered). Off by default, one config flag to
enable. Query history with store.journal.get_entries(...) and validate the
chain with store.verify_journal(), which audits whatever chain is on disk
independent of the current journal.enabled state — the store.journal writer
handle does not exist while journaling is off, but the chain earlier sessions
wrote is still in journal_entry and still needs verifying.
→ See docs/audit-trail.md for enabling, querying,
verification, and the security model (what "tamper-evident" does and does not
guarantee).
Multi-Service Isolation
Run multiple independent databases under one EngravaManager:
from pathlib import Path
from engrava import EngravaManager
async with EngravaManager(data_dir=Path("./data")) as mgr:
agent_a = await mgr.get_store("agent-a")
agent_b = await mgr.get_store("agent-b")
# Completely isolated databasesMCP Server
Want Engrava as a memory server for your agent? The MCP server ships as its own
package, engrava-mcp — a native stdio server (no HTTP shim) with read
tools, optional write tools, attachable engrava:// resources, and guided
prompts, for any MCP client (Claude Desktop, Claude Code, Cursor, Windsurf,
VS Code):
uvx engrava-mcp # or: pip install engrava-mcpengrava-mcp pulls engrava in transitively, so installing it also gives you
the import engrava library. See the
engrava-mcp package for install,
client configuration, the full tool/resource/prompt reference, and read-only
mode.
uvx vs. a persistent install. uvx engrava-mcp (equivalently uv tool run engrava-mcp) installs into "an ephemeral virtual environment in the uv
cache directory" per uv's own --help text — fine for the lexical/network
profiles above, which add nothing heavier than httpx. Once you are on the
local profile (engrava-mcp[local], the same sentence-transformers +
torch download as engrava[embeddings-local]), uv tool install 'engrava-mcp[local]' is the better fit: it installs once into a persistent
environment — the same uvx engrava-mcp invocation then reuses that
installed environment instead of resolving a fresh ephemeral one — and a
later uv tool upgrade engrava-mcp re-pays only the changed packages, not
the whole dependency tree.
CLI
engrava --db mydata.db info # Database stats
engrava --db mydata.db query "FIND thoughts WHERE thought_type = 'OBSERVATION' LIMIT 5"
engrava --db mydata.db snapshot -o backup.jsonl
engrava --db mydata.db restore -i backup.jsonl
engrava --db mydata.db gc # Collect ARCHIVED thoughts + their edges/embeddings/actions
engrava --db mydata.db migrate # Ensure schema is up-to-date
engrava --db mydata.db export -o portable.jsongc physically deletes ARCHIVED thoughts together with every edge touching one
on either end — including edges whose other end is still live — their embeddings
and the actions sourced from them, then reconciles the vector index by removing
every vec0 row no embedding row owns; on a vec0-indexed store where
sqlite-vec cannot be loaded — most commonly because engrava[vec] is not
installed — a pass that is about to delete stops before deleting anything and
exits 1 rather than stranding those vectors in an index nothing can then reach
them through.
engrava info's --format json output carries every field of the metrics
snapshot exposed by await store.metrics(), with the snapshot's own schema
version renamed metrics_schema_version, plus two fields the snapshot itself
does not carry, db_path and database_schema_version — see Upgrade
Guide. The default text output is a shorter summary
of that same data, not the full snapshot.
See the CLI reference for every command and option.
Architecture
SQLite with WAL mode for concurrent reads
Frozen Pydantic models — immutable domain objects
Async-first — all I/O via
aiosqliteHook-based extension — zero monkey-patching
Template method pattern — subclass
SqliteEngravaCorefor extended schemasZero external services — everything runs locally in-process
Documentation
Documentation Index — choose a path by task: learn, build, operate, extend, or migrate
Core Concepts — the mental model (thought, edge, reflection, cycle, …) — start here
The Bi-temporal Model — the optional valid-time axis: query a fact as of any instant,
invalidatewithout deletingPositioning — when Engrava is (and isn't) the right tool, and how it compares
Quick Start — 5-minute setup guide
Tutorial — build a small notes memory end to end
Recipes — copy-paste snippets for common tasks (store a turn, retrieve context, TTL, dedup, …)
Building a memory-backed agent — the end-to-end agent turn loop (ingest → retrieve → generate → consolidate)
Migrating from another memory system — concept mapping, porting calls, bulk import, and scoping/multi-tenancy
Embeddings — wiring a real embedding provider (local / OpenAI / Ollama / HuggingFace / custom)
MCP server (
engrava-mcp) — expose a store to MCP clients (Claude Desktop, Claude Code, Cursor, Windsurf, VS Code) via the standalone server package: install, run, client config, tools/resources/promptsConfiguration — YAML config format and options
Upgrade Guide — compatibility matrix, backups, and troubleshooting
Extensions — Writing custom extensions and hooks
Observability — Metrics snapshot API
Audit Trail — Tamper-evident hash-chain journal (enabling, querying, verifying, security model)
Evidence and Conflicts — model claims, provenance, contested facts, and caller-owned clarification
Security — trust boundaries, data egress, tenant isolation, encryption posture, and journal limits
Error Handling and Recovery — decide when to retry, repair input, or replace a store
Benchmarks — release gates, versioned observed values, and LongMemEval
API Reference — Full protocol and class reference
CLI Reference — every
engravacommand and optionGlossary — quick definitions of every Engrava term
MindQL — Query language syntax and examples
Troubleshooting — symptom → cause → fix for common errors
FAQ — quick answers (LLM/keys, embeddings-optional, scale, concurrency, backups, …)
Performance & Scaling — the vector-backend switch, bulk-ingest, and dreaming cost at scale
Data Lifecycle & Retention — lifecycle states, TTL, archive-vs-delete, GDPR erasure, disk reclamation
Deployment — process model, database files on disk, containers, graceful shutdown
Concurrency — the WAL single-writer model, busy timeout, and per-service isolation
Backup & Recovery — WAL-safe backups, snapshot vs file copy, restore verification
Known Limitations — Platform notes and constraints
Development
make install # deps + dev extras + local git hooks -- from the primary checkout
make gate # fast: hooks-active + lint + format check + type check + goldens drift, ~70s
make check # lint + format check + type check + the full test suite with coverage (unchanged; no hooks-active, no goldens drift)make install also wires a commit-msg hook that lints the message of the
commit a squash merge creates — including on a local branch that never opens
a pull request, where nothing else does. It refuses to install from a linked
worktree (core.hooksPath is shared across all of them). See
CONTRIBUTING.md
for what it covers and what it does not.
License
MIT — see LICENSE for details.
docs/upgrade.md
Upgrade Guide
Most users can run pip install --upgrade engrava safely. Database migration is
automatic on first connection, and the upgrade path is validated in CI before
minor releases.
What Happens During Upgrade
Core schema migration runs automatically on the first
ensure_schema()call.Existing data is preserved; migrations are forward-only.
Extension schema migrations are also applied when an installed extension declares them.
In practice, most applications do not need a separate migration step. If your
app already calls ensure_schema() during startup, that call performs the
upgrade.
Rolling upgrades (multiple workers)
If several processes share one database file, whether you can do a rolling upgrade (start new-version workers while old-version workers are still running) depends on whether the new version changes the schema.
How migrations work: the core schema is versioned by SQLite's PRAGMA user_version. On the first ensure_schema(), Engrava runs each pending
vN → vN+1 step in turn (forward-only). Almost every step is one atomic
transaction: its DDL/DML and the PRAGMA user_version stamp that marks it
done run inside a single explicit BEGIN / COMMIT, so a step that raises
partway is rolled back in full — the database comes back exactly as it was
before that step began, and the version is never stamped over a partial
change. The one exception is the step that adds foreign keys to edge,
embedding and action (user_version 11 → 12): it must toggle
PRAGMA foreign_keys off and back on around rebuilding those three tables,
and that pragma is a documented no-op while a transaction is open — a
constraint confirmed unchanged across SQLite versions from 3.31 (2020) through
current releases. That step commits its three table rebuilds together in
their own internal transaction, but the step as a whole is not one atomic
unit: a failure after the rebuilds commit but before the step returns can
leave some of edge / embedding / action already carrying their foreign
key while user_version is still unstamped.
Every step — atomic or not — is also idempotent: it checks its own
postcondition and skips work already done, and PRAGMA user_version is
written and committed only after a step returns successfully. That is what
makes re-running ensure_schema() after any failure safe: for an atomic
step, the retry redoes the whole step from the unchanged pre-failure state;
for the one step that is not atomic as a whole, the retry sees whatever
partial foreign-key state the failed attempt left behind and finishes only
the part that did not already complete. Either way, a step that raises never
redoes completed work or double-applies anything. Most steps are additive
(new columns, tables, and indexes), but some rebuild a table in place (create
a new table, copy rows, drop the old, rename) — so the on-disk shape of a
table can change across a migration.
What that means for a rolling deploy:
Patch upgrades do not change
user_version(e.g.0.3.0 → 0.3.1). From 0.7.0 onward a release gate enforces this: the pipeline compares the core schema stamp against the last released tag and blocks the PyPI publish when the schema moved on a patch-only bump. Before 0.7.0 it was a rule the project followed, checked by nothing. Old and new workers are expected to run side by side on a patch upgrade on that basis.One limit worth knowing: it covers the recorded core schema version only. The gate itself runs inside the release pipeline's
preparestep, before the git tag, the push, and the GitHub Release are created, so a release that violates the rule is refused before any of those exist. If you want certainty rather than a policy before rolling workers across a release, comparePRAGMA user_versionbefore and after the upgrade yourself.ensure_schema()also applies any pending extension schema migrations, tracked separately inextension_schema_migrations, which auser_versioncomparison does not reveal; check an installed extension's own migration history too before trusting mixed-version workers are safe.Minor upgrades that run migrations are not guaranteed to be backward-readable. Once the first new-version worker calls
ensure_schema()and a table is rebuilt, an old-version worker may no longer match the new on-disk shape. Do not run old and new workers concurrently across such an upgrade.
Recommended procedure for a schema-changing (minor) upgrade:
Back up the database (see Before You Upgrade).
Quiesce writers — stop the old workers (or take a brief maintenance window) so no old-version process writes during the migration.
Run the migration once — let a single new-version process call
ensure_schema()(or runengrava migrate) to completion.Start the new workers against the migrated database.
When you are unsure whether a target release changes the schema, treat it as schema-changing and follow the quiesce procedure — it is always safe. The compatibility matrix notes which listed upgrades change the schema.
Before You Upgrade
These steps are recommended, not required:
# Back up, then upgrade. The sqlite3 CLI's .backup command uses SQLite's
# online backup API, so the copy is consistent and includes changes still
# in the WAL. `test -f` comes first because the sqlite3 CLI creates an
# empty database at a path that does not exist. Each step runs only if the
# one before it succeeded.
test -f my-data.db &&
sqlite3 my-data.db ".backup my-data.db.bak" &&
pip install --upgrade engravaCreate a copy of the SQLite database file before the upgrade. In WAL mode a bare
cpof just the.dbcan miss data still in the-walfile — use.backup(above), or copymy-data.dbtogether withmy-data.db-walandmy-data.db-shm. See Backup & Recovery for all the WAL-safe options.Review CHANGELOG.md for breaking changes and database notes.
If you ship custom extensions, make sure their schema migrations are included in the version you are about to install.
After You Upgrade
Use the CLI to confirm the upgraded database opens correctly:
engrava --db my-data.db info
engrava --db my-data.db migrateengrava infoconfirms the database is readable and reports current counts.engrava migrateis safe to run after upgrade; it re-checks that schema is up to date.engrava gcis optional if you want to remove archived or expired data after the upgrade. Note that under SQLite's defaultauto_vacuum=NONE,gcdeletes rows but does not shrink the database file — freed pages return to SQLite's free-list. To reclaim file size, runVACUUM. See Data lifecycle → reclaiming disk space.If the upgraded install dropped the vector extra,
gcwill refuse rather than run. A database that carries anembedding_vectable from an earlier run withsqlite-veccannot be collected without it: agcpass that is about to physically delete stops before deleting anything and exits1withInstall 'engrava[vec]' and retry, because removing the rows without removing their vectors would strand those vectors in the index. The same refusal follows any other reasonsqlite-vecfails to load — an unsupported build, an OS error, a SQLite error — not only a missing extra. Reinstall aspip install 'engrava[vec]'and retry.--dry-runis never refused, and neither is a run with nothing to delete; butgc --expiredunder the defaultttl.strategy: archivestops after archiving only when it actually archived something, and with no expired rows falls through to the archived-collection pass, which is refused when there are archived rows to collect. See CLI reference →gc.
If Migration Fails
Migration errors should include the failing SQL or the extension responsible for the failure.
Recommended recovery order:
Restore from your
.bakcopy.Re-run the upgrade in a clean virtual environment.
Open an issue with the error message,
engrava infooutput, and whether the failure happened in core schema migration or an extension migration.
When reporting the problem, redact file paths and application-specific content if needed, but keep the SQL error and schema version details intact.
Downgrade Policy
Downgrades are not supported for 0.x releases. Migrations are forward-only.
If you must move data into an older version, use an export/import flow instead of opening the upgraded database file directly:
engrava --db my-data.db snapshot -o backup.snapshot.jsonl
engrava --db new-old-version.db restore -i backup.snapshot.jsonlNote: a snapshot exports thoughts, edges, embeddings, and actions, but not the audit journal (
journal_entry).new-old-version.dbabove is a fresh target, so it starts with an empty journal regardless. That is specific to a fresh target, though: restoring the same snapshot with--clearinto an existing journalled database empties its journal too. Restoring without--clearmerges in, and if that target's journal is non-empty, refuses any record that collides with an existing row (primary key orUNIQUEconstraint), rolling the whole restore back — see the 0.6 → 0.7 notes below if you have a scripted merge restore into a journalled database.--orphan-journal-entriesallows the merge anyway and restores the prior behaviour, where it can orphan journal entries even when no incoming ID collides with what the journal already describes: a duplicate(from_thought_id, to_thought_id, edge_type)triple replaces an existing edge, and replacing a thought cascades to that thought's own edges, embeddings, and actions — neither needs its own ID to collide. The journal entries describing the earlier row stay behind, andverifystill reports the chain as valid. If you need the audit history preserved, take a physical file backup instead — see Backup & Recovery.
Compatibility Matrix
| From | To | Supported | Notes |
|---|---|---|---|
| 0.2.0 | 0.3.0 | Yes | No schema change (user_version unchanged). No 0.2.2 release exists — git tag, CHANGELOG.md (whose 0.3.0 entry compares directly against v0.2.0), and the upgrade-path CI spec (which pins the immediately-preceding released version, not 0.2.2) all agree there is no intermediate release; go directly from 0.2.0 to 0.3.0 |
| 0.3.0 | 0.3.1 | Yes | Patch-level upgrade; no schema change (user_version unchanged) — safe to roll across workers |
| 0.3.x | 0.4.0 | Yes | Schema-changing minor upgrade — adds the valid-time columns (additive, zero data loss). Back up first and follow the rolling-upgrades note |
| 0.4.x | 0.5.0 | Yes | Schema-changing minor upgrade (user_version 14 → 18), although the library API is drop-in. Breaking for MCP-server users only: the engrava[mcp] extra and the in-engrava engrava-mcp command are removed — the server moved to the standalone engrava-mcp package (see the 0.4 → 0.5 note) |
| 0.5.0 | 0.6.0 | Yes | Schema-changing minor upgrade (user_version 18 → 20), with two additive columns. Default retrieval now excludes archived thoughts, and wrong-dimension query vectors raise a typed error. An edge decay_multiplier of 0.0 no longer reads back as 1.0, and a later update no longer rewrites it to 1.0 — values a 0.5.x update already overwrote stay overwritten. Back up, quiesce shared-store workers, migrate once, and review the 0.5 → 0.6 notes |
| 0.6.x | 0.7.0 | Yes | Schema-changing minor upgrade (user_version 20 → 21): every thought / edge / action row gains a revision INTEGER NOT NULL DEFAULT 0 column, and stored timestamps are rewritten into one canonical UTC form with no instant changed; EngravaMetrics.schema_version separately moves 1 → 2; and engrava --format json info loses its schema_version key in favor of metrics_schema_version + database_schema_version (see below). update_thought, restore_thought, update_edge and update_action now check and increment revision atomically on every guarded write, so a write that lands after another guarded write touched the same row — including from a second connection or process — raises StaleDataError instead of silently overwriting; update_edge and update_action could never raise it before. No public method signature changed. Behaviour change: when the resolved recency weight is 0.0 and a cognitive-cycle reference (current_cycle, explicit or via cycle_provider) is present, the query-less fallback path now treats recency as fully off instead of still decaying by cycle — which can change result order for stores with heterogeneous thought priorities. recency_now (transaction-time) callers are unaffected; that axis was already correct. Also in this release: three new EngravaError subclasses (WriteContentionError, WriteLockTimeoutError, DedupLockReentryError) can now come out of the dedup and guarded-write paths; a new public override seam, prepare_thought_for_insert(), restores pre-insert customization that get_or_create() / upsert_by_hash() had silently stopped routing through an overridden create_thought(); gc --dry-run now names everything the real run deletes (edges, embeddings, and actions, not only orphaned edges); reconciliation, the vector-index purge and search now resolve a vector through its thought row, and delete_thought issues its own deletes for a thought's edge, embedding and action rows; a corrupt or truncated database file now makes the CLI exit with an error instead of hanging; and restore now refuses an embedding row with an empty owner_type/owner_id, a non-ISO-8601 created_at, or a non-positive dimension — every such row was already invalid on every prior release, so this only ever rejects a snapshot that already carried a broken record; and a merge restore (no --clear) into a target whose journal_entry table is non-empty now refuses any record that collides with an existing row and rolls the whole restore back instead of replacing it, unless --orphan-journal-entries is also given — journaling is opt-in and the CLI never enables it, so this only reaches a target that already has journaling on. Also in this release, the dreaming clustering cohesion gate (cluster_quality_cohesion_threshold) now divides by both vectors' norms instead of using a raw dot product, moving the score for most non-unit-vector providers. Review the 0.6 → 0.7 notes |
| 0.7.0 | 0.7.1 | Yes | Patch-level upgrade; no schema change (user_version unchanged) — safe to roll across workers. Every change is a defect fix, and each one changes what some caller sees: a MindQL extension handler receives a SELECT-only accessor instead of the live connection; a MindQL WHERE with a dangling AND, or nested more than 256 levels deep, raises; engrava gc keeps pinned and protected-priority rows unless --include-pinned is given, and TTL never expires a pinned row; top_k above the per-arm pool returns a full page; a directly constructed store, or one EngravaManager builds with neither base_config nor default_search, applies recency weight 0.1 when given a recency reference; and EngravaManager gains base_config to build fully configured stores. Review the 0.7.0 → 0.7.1 notes |
| 0.7.1 | 0.7.2 | Yes | Patch-level upgrade; no schema change (user_version unchanged), safe to roll across workers. When a suspend_auto_commit() block fails and its rollback fails too, the store is now quarantined before its write lock is released, so a waiting or later operation that reaches the database through it raises ConnectionQuarantinedError. A derivation that finds its derived thought or provenance edge already stored no longer leaves the store holding the write lock. numpy is no longer capped below 2.3, and Python 3.14 is supported. Review the 0.7.1 to 0.7.2 notes |
For any upgrade not listed, the rule of thumb is: patch upgrades within a
0.x.* line do not change the schema and are low-risk; minor upgrades
(0.X → 0.(X+1)) may run schema migrations — back up first and read the
rolling-upgrades note below.
Version Notes
0.7.1 -> 0.7.2
A patch release: no schema change (user_version unchanged), safe to roll across workers.
A failed rollback now quarantines the store. When the body of a suspend_auto_commit() block raises, the store
rolls back the outermost transaction. If that rollback failed too, 0.7.1 released the write lock with the
transaction state unknown, and a write already waiting on the lock, or any later write, could commit the abandoned
writes together with its own. 0.7.2 quarantines the store before it releases the lock: the waiting write, and every
later operation that reaches the database through that store, raises ConnectionQuarantinedError; a call that
returns without touching the database, such as a search with an empty query, returns as before. The body's
exception still reaches the caller, with the rollback error as its __cause__. If the rollback was cancelled, the
cancellation propagates instead. A rollback that succeeds behaves as before.
What to do. Handle it as the other quarantine causes: close the store, open a new one, and inspect durable state before you repeat any write. See Error handling.
Re-deriving a thought no longer leaves the database locked. With a derived-record producer configured, a
derivation that found its derived thought, or its provenance edge, already stored left the transaction it had
opened still open in 0.7.1. That happened when derive_existing() ran again on a thought, and when a new thought's
producer returned content already stored without a provenance edge. The store then held SQLite's write lock until
its next commit, and writes from other connections failed with database is locked. 0.7.2 closes that transaction
with a rollback; a transaction the caller already holds is left as it was. Stores without a producer are not
affected.
numpy is no longer capped below 2.3. No numpy below 2.3.2 ships a wheel for Python 3.14, so on 3.14 the cap
made every install compile numpy from source. Without the cap, an upgrade on any Python version may also move
numpy to 2.3 or later. The floor, 1.26.0, is unchanged, and a pin your own project sets still applies. For
development, the dev extra now needs mypy 2.4.0 or later and declares httpx itself.
Python 3.14 is tested in CI and listed in the package classifiers. requires-python is unchanged.
0.7.0 -> 0.7.1
A patch release: no schema change (user_version unchanged), safe to roll across workers. Every change below is a
defect fix, but each one changes what some caller sees.
Breaking change for MindQL extension authors: a custom command's handler no
longer receives the live connection. MindQLExtension.handler's first
argument used to be the raw aiosqlite.Connection the store runs on, which
let a handler run any SQL through it — including writes, DDL, and PRAGMA —
through what was documented as a read-only query language. The handler now
receives a ReadOnlyAccessor wrapping that connection: its only capability is
execute(), which accepts a single SELECT statement (the same guard the
SELECT passthrough already enforced) and returns a restricted cursor
(fetchone, fetchall, fetchmany, async iteration, and description — no
execute()-family method, no connection). A handler that only reads
through a single parameterised SELECT — the shape every documented example
already used — works unchanged; a handler that wrote through the connection
it used to receive now raises MindQLParseError (for anything that is not a
SELECT) or AttributeError (for a write attempted through the returned
cursor, which carries no execute()-family method either). No schema
migration is involved. See Extensions and
Extension hooks for the updated
handler contract, and MindQL for the
accessor's read-only guarantee.
Behaviour change: MindQL's WHERE parser rejects two shapes it used to run.
A leading, trailing or doubled
AND(FIND ... WHERE source = x AND) used to be dropped silently, so the query ran without it. It now raisesMindQLParseError.A
WHEREnested more than 256 parentheses deep now raisesMindQLParseError. Before, such a query parsed up to Python's recursion limit, and deeper input raisedRecursionError.
Also in this release:
ANDandORare recognised only as whole words outside a quoted value. A value ending in one, followed by a boolean operator, used to fail to parse:source = vendor OR priority = 1. It now parses.A quoted value that contains
ANDorORDER BYis read as a value, not as grammar.A doubled quote inside a single-quoted value is one literal quote, SQL-style.
source = 'O''Brien'used to match the raw text'O''Brien'and now matchesO'Brien. The same value inside anIN (...)list used to raise, and now parses.
See MindQL.
The SELECT passthrough's single-statement check reads SQL the way SQLite does. It used to reject any ;
left after blanking single-quoted literals. A SELECT with a ; inside a comment or a quoted identifier is now
accepted, and the one statement may be followed by ;, whitespace and comments. A second statement hidden behind a
quote inside a comment, which the old check let through, is now refused. The same check guards an extension
handler's ReadOnlyAccessor.execute().
EngravaManager can build fully configured stores. It used to give each service's store only the PRAGMAs,
the embedding provider and the vector backend, so the journal, TTL, metrics, extension manifests, access tracking,
hygiene policy, derive gates, dreaming, hooks and require_embedding never reached a manager-built store. Two new
optional keywords fix this: base_config applies a full EngravaConfig to every store the manager builds, and
embedding_provider_factory supplies a caller-built provider per service. EngravaManager.from_config() also
accepts a full EngravaConfig. Without base_config, the manager wires a store as before. A store it builds
with neither base_config nor default_search has no SearchConfig, so the recency default change below reaches
it too; with base_config, a store takes base_config.search. The CLI's snapshot and
restore commands now pass the loaded configuration to the manager as base_config. See
Configuration and Deployment.
top_k above the per-arm pool returns a full page. fts_top_k and vector_top_k used to cap each arm's
candidates below top_k, so search_hybrid(), and recall() through it, returned fewer results than matched once
top_k passed the pool size (50 by default). Each arm's pool is now at least top_k. A top_k at or below the
pool is unaffected.
The VS Code MCP example loads as shipped. examples/mcp-client-config.vscode.json nested its servers under an
mcp key, which .vscode/mcp.json does not read. It now holds the servers object at the top level, with
"type": "stdio". See Examples.
Behaviour change: engrava gc keeps pinned and protected-priority archived
rows by default, instead of deleting every ARCHIVED row unconditionally.
No schema migration is involved.
Who is affected. Anyone who runs engrava gc (with or without
--expired) against a store that pins thoughts, or configures
hygiene_policy.protected_priorities, and expects every ARCHIVED row to be
removed. gc now deletes only ARCHIVED rows that are neither pinned nor at
a protected priority (P1 unless --config names an engrava.yaml with a
different hygiene_policy.protected_priorities), and reports how many it kept
for that reason. Pass --include-pinned to restore the previous unconditional
behaviour. --dry-run reports both the would-delete and would-keep counts.
See Data lifecycle → Running cleanup.
Also in this release: gc --expired's TTL pass (cleanup_expired(),
reached directly, through the TTL cadence, or through gc --expired) now
skips a pinned row regardless of an expired TTL, so a store-wide default TTL
no longer overrides pinning. A skipped row is counted in the new
CleanupResult.pinned_kept_count field instead of expired_count; it is 0
whenever nothing pinned is past its TTL, and existing keyword construction of
CleanupResult is unaffected. A protected-priority row is not exempt from
TTL and still expires. When
--config names an engrava.yaml with journal.enabled: true, gc (both the
archived-collection pass and the --expired TTL pass) now also journals what
it deletes or archives, where it previously journaled nothing regardless of
that setting — see Audit Trail. The
orphan-REFLECTION sweep that runs during dreaming consolidation and before
Memory Hygiene's own GC stage now also respects pinning and protected
priorities, and retires a REFLECTION whose entire source cluster was
hard-deleted (previously it stayed ACTIVE indefinitely in that case) — see
Dreaming → Orphan sweep. No public method
signature changed.
Behaviour change: a directly constructed store (no SearchConfig) now
resolves an omitted recency_weight to 0.1, not 0.0. No schema
migration is involved.
Who is affected. Anyone who calls search_hybrid() — directly, or
through recall(), which never passes an explicit recency_weight of its
own — with a recency reference (a cognitive cycle, current_cycle explicit
or via cycle_provider, or a transaction-time recency_now) on a store
built SqliteEngravaCore(conn, ...) with no search_config argument — which includes a store
EngravaManager builds with neither base_config nor default_search — and
(for a direct search_hybrid() call) does not pass an explicit
recency_weight. That one construction path used to carry
its own literal (0.0) for this default; every other path — a default
SearchConfig(), an explicit SearchConfig(...), or from_config with no
override — already resolved it to 0.1. An explicit per-call
recency_weight to search_hybrid() still wins, exactly as before. So does
an explicit SearchConfig you built with a different value for that same
setting. search_reflections_only() is not affected: it takes no
per-call recency_weight and consults no cycle_provider, and its own
recency fallback already resolved to 0.1 before this release.
What changed. The two defaults disagreed with each other and with the
documented default (see Hybrid Search). Now every
construction path reads this weight from the same place — SearchConfig's
own field default — so an omitted search: YAML section and search: {}
resolve identically too. Because recency is active whenever a
recency reference is present and the resolved weight is above 0.0, a
direct-constructor store that calls search_hybrid() (or recall()) with
current_cycle, a cycle_provider or recency_now now activates the recency signal where it previously
stayed silent, which can reorder results and change the raw scores
HybridSearchResult reports — the same two kinds of effect
the 0.6 -> 0.7 fallback-path change below describes, from the
opposite direction (recency turning on instead of off).
What to do. If you relied on a direct-constructor store ranking without
recency by default, pass recency_weight=0.0 per call to search_hybrid()
— recall() has no such per-call override — or construct with
search_config=SearchConfig(default_recency_weight=0.0) (or the equivalent
default_recency_weight: 0.0 in from_config's search: section) to keep
the previous ranking on either method.
0.6 -> 0.7
Behaviour change: the dreaming clustering cohesion gate now computes a true cosine similarity instead of a raw dot product. No schema migration is involved.
Who is affected. Anyone running the dreaming consolidation loop with
gates.cluster_quality_enabled on. Two populations, executed separately:
Any provider whose vectors are not close to unit length —
HuggingFaceProvider,OllamaProvider,OpenAICompatibleProvider, andCallbackProviderall return whatever magnitude the remote model or user callback produces, and the score can move by a large, provider- and pair-dependent amount (see the disagreement case below, an old score of1.5dropping to a true cosine of~0.29).SentenceTransformerProvideroutput, and any other already-near-unit vector, is not exempt: it moves too, by a smaller amount that is still not negligible. Executed: a pair whose components were originally rounded tofloat32precision (0.6000000238418579,0.800000011920929against[1.0, 0.0]— the value a[0.6, 0.8]direction normalises to in single precision, once that nativefloat32output is widened to Python'sfloat) scores0.6000000238418579under the old raw-dot- product formula and0.6000000095367428here — a difference of about1.4e-8. That is roughly a hundred million times the size of a singlefloat64rounding step, because the source of the discrepancy is notfloat64rounding at all: it is the residual imprecision the vector already carried from being rounded tofloat32before this function ever sees it, which only becomes visible once the norm is recomputed in double precision. Acluster_quality_cohesion_thresholdset anywhere in(0.6000000095367428, 0.6000000238418579)— not only a value sitting exactly on one of the two scores — flips this specific pair's contribution.
What changed. cluster_cohesion_score() (backing the
is_low_cohesion gate and its cluster_quality_cohesion_threshold) used
to compute the mean pairwise dot product of member embeddings,
documented as a cosine similarity on the assumption that every provider's
vectors are L2-normalised. That assumption held for one shipped provider
and not the others, so for the rest the gate's score varied with vector
magnitude, not just direction — two members embedded by the same provider
could look "more cohesive" than another pair purely because that provider
happened to produce longer vectors for them, independent of topical
similarity. The gate now divides by both vectors' norms
(dot(a, b) / (||a|| * ||b||)), which approximates the cosine similarity
the documentation and the gate's name have always claimed, for every
provider — see below for where floating-point arithmetic, not any
provider's magnitude, still keeps this from being exact.
Where the fix lives, and why. Normalisation was added at the gate
(_pairwise_cosine_similarity, in
engrava/extensions/dreaming_cluster_quality.py), not at the embedding
provider boundary. Normalising at the boundary would change what every
other consumer of a provider's vectors sees (vector-index storage, dedup
similarity, any future direct dot-product caller) for a fix scoped to one
gate; normalising only inside the gate keeps every other embedding
consumer byte-identical to before.
Zero-length vectors. A zero vector has no direction, so its cosine
with anything is undefined. _pairwise_cosine_similarity returns 0.0
for that pair, deliberately, rather than raising or guessing a value — the
conservative choice for a cohesion gate, since a zero vector normally
signals an embedding failure or empty input, and treating it as similar to
anything would let a broken vector prop up a cluster's score. This is not
an observable behaviour change by itself: the old raw-dot-product formula
already produced 0.0 for any pair involving a zero vector too, since a
dot product against the zero vector is trivially zero regardless of the
other vector's direction. What changes is that this is now a stated
policy rather than an accident of the arithmetic — if a future change
altered how the non-zero-vector case is computed, the zero-vector case
would no longer coincidentally agree with it unless someone remembered
this paragraph.
Non-finite and overflowing input can now land a cluster on the other side of the gate — executed, two directions. Neither direction was designed; both are accidents of the arithmetic, on the old formula and the new one alike, and this release does not attempt to make either side "correct" — that is a separate, filed decision. It only documents what actually happens.
A member vector containing
nanorinf, paired with a member vector whose own norm is exactly (or underflows to) zero, now scores0.0instead ofnan. Executed:[nan]paired with[0.0]scorednanunder the old raw-dot-product formula (nan * 0.0isnan) and scores0.0here — the zero-norm check on the other, genuinely-zero vector fires before thenanin the first vector is ever examined.[inf]paired with[0.0]does the same, for the same reason (inf * 0.0is alsonan). Becausenan < cohesion_thresholdisFalsebut0.0 < cohesion_thresholdisTruefor the default0.40, a cluster containing this pair moves from admitted (old formula) to rejected (this release).An anti-parallel pair whose magnitudes overflow
float64now scoresnaninstead of a signed infinity. Executed:[1e200]paired with[-1e200]scored-infunder the old raw-dot-product formula (1e200 * -1e200overflows to-infdirectly) — correctly rejected, since-infis below any threshold — and scoresnanhere, because both norms overflow toinfand-inf / infisnan. Becausenan < cohesion_thresholdisFalse, this cluster moves from rejected (old formula) to admitted (this release) — the opposite direction from the case above.Not every overflowing pair changes: an orthogonal pair whose components also overflow (
[1e200, 0.0]against[0.0, 1e200]) scores0.0under both formulas (the dot product itself is0.0, so the division is0.0 / inf, notinf / inf) and stays rejected either way.
Your cluster_quality_cohesion_threshold was tuned against a different
function, on every provider — check which direction before assuming a
regression, even on SentenceTransformerProvider. The default (0.40)
was calibrated on SentenceTransformerProvider output; the executed
float32-precision case above (a ~1.4e-8 shift) shows that provider is
not exempt, though the shift there is far smaller than on a
non-normalising provider. For a provider whose vectors are not close to
unit length, the relationship between the old score and the new one is
exact: raw_dot_product = ||a|| * ||b|| * true_cosine. That means
the direction of the shift for any one pair depends on both the
product of that pair's norms and the sign of its cosine — not on the
norm product alone, so different pairs in the same corpus, under the same
provider, can move in opposite directions:
||a|| * ||b|| > 1(common for a model whose native output is not scaled to unit length) with a positive cosine: the old score reads higher than the true cosine — reported cohesion drops after the upgrade (the executed disagreement case below is exactly this shape: an old score of1.5, outside the[-1, 1]range a cosine can even take, drops to a true cosine of~0.29).||a|| * ||b|| > 1with a negative cosine: the old score reads lower (more negative) than the true cosine — reported cohesion rises after the upgrade.||a|| * ||b|| < 1with a positive cosine: the old score reads lower than the true cosine — reported cohesion rises after the upgrade.||a|| * ||b|| < 1with a negative cosine: the old score reads higher (less negative) than the true cosine — reported cohesion drops after the upgrade. (Executed:[0.5, 0]paired with[-0.5, 0], norm product0.25 < 1, old score-0.25, new score-1.0— cohesion drops, not rises, because the pair is negatively correlated; norm product alone would have predicted a rise.)
A single corpus commonly contains both positively- and negatively- correlated pairs, so it can move in both directions at once under one provider. Computing your provider's typical vector norms tells you which norm-product regime you are in, but not which way any given pair's score moves — that also needs the sign of its cosine, which is exactly what this change starts to account for.
What to do. Re-run your cohesion threshold calibration against the
new score on a representative sample of your own clusters, whichever
provider you are on. On SentenceTransformerProvider the shift is small
(the executed case above moves a 0.6-ish score by ~1.4e-8) but not
zero, so a threshold that happens to sit within that gap can flip; on a
non-normalising provider the shift can be much larger and does not have
a single expected direction — do not infer one from typical norms alone,
since pairs in your corpus can move oppositely (see above). Separately,
if any of your embeddings can be nan, inf, or large enough to
overflow float64 when squared, review the non-finite/overflowing
section above: this release can flip a cluster from admitted to rejected
or the reverse for those specific input shapes, independent of your
provider's typical magnitude.
Breaking behaviour change: a resolved recency weight of zero now fully disables cognitive-cycle recency, including on the query-less fallback path. No schema migration is involved.
Who is affected. Two conditions, both required: the resolved
recency_weight is 0.0, and a cognitive-cycle reference is present — an
explicit current_cycle, or one resolved through a configured
cycle_provider. The resolved weight is 0.0 in three cases: an explicit
recency_weight=0.0; a directly constructed SqliteEngravaCore with no
SearchConfig at all, which resolves an omitted recency_weight to 0.0 —
those callers are affected without ever passing the argument, so do not rule
yourself out just because your code never mentions recency_weight; or a
supplied SearchConfig whose default_recency_weight is 0.0. (A default
SearchConfig() and from_config with no override both resolve to 0.1, not
0.0 — only construction with no SearchConfig object at all defaults to
0.0, which is exactly why an omitted argument can mean "off" on one
construction path and "on" on another.) Within that population, the change
is on the fallback path — FTS inactive or skipped, and vector search
inactive, for that particular query. Two separate things change there, on
two separate conditions, and they are not the same population: result
order changes only on a store where another signal is also active
(commonly priority_weight, which defaults to 0.05); the raw per-row
score HybridSearchResult reports changes regardless of whether any other
signal is active at all — see the executed, zero-other-signal case below.
Who is not affected: recency_now callers, on both counts, executed.
If you use recency_now (transaction-time recency) instead of
current_cycle, this change does not reach you — order or score. Executed
directly with identical inputs: the transaction_now path returns the same
scores in the same order on 0.6.x and 0.7. A resolved weight of 0.0 with no cognitive-cycle reference
present at all (no explicit current_cycle, no configured cycle_provider)
was likewise already a no-op on both versions — there is no reference for
the cycle-decayed score to gate.
What changed. On 0.6.x, a resolved recency_weight of 0.0 correctly
kept 'recency' out of HybridSearchResult.backends_used, but the fallback
path still computed a cycle-decayed score for each row — when a cognitive-cycle
reference was present — and used it as that row's score ahead of the sort: the
weight gated the label, not the number. On 0.7, the cognitive-cycle axis is
genuinely inert whenever the resolved weight is 0.0: every row on this
path gets a flat 0.0 recency contribution to its score, matching the
transaction-time axis, which was already correct on 0.6.x. The row's
total score is this flat 0.0 only when nothing else contributes to it —
see the split below for what happens when another signal, such as priority,
is also active.
Why this can reorder results, not just shrink a score. The fallback path
adds every active signal's contribution and re-sorts. With the recency term no
longer contributing anything, whichever row has the higher priority boost can
now win outright — previously, a fresh-but-low-priority row could still beat a
stale-but-high-priority one on the strength of an undecayed recency score that
a resolved weight of 0.0 was supposed to have turned off.
Concrete example, on defaults (current_cycle=100, recency_half_life=50,
priority_weight=0.05), with the recency weight resolved to 0.0:
| Row | 0.6.x score (leaked recency) | 0.7 score (fixed) |
|---|---|---|
fresh, Priority.P4 (updated_cycle=100) | 1.0 — won | 0.0 |
stale, Priority.P2 (updated_cycle=0) | 0.85 | 0.6 — wins now |
The scores themselves change even with no other signal active at all —
order does not, but a reader who trusts the number is still affected.
Executed directly (priority_weight=0.0 too, so nothing else contributes):
the same two rows above, with every other weight at 0.0, score [1.0, 0.25] on 0.6.x and [0.0, 0.0] on 0.7 — the raw, un-weighted cycle-decayed
value the fallback path used to return as each row's score, unconditionally,
whenever a cognitive-cycle reference was present. Order is unchanged (both
rows keep the same relative position — 1.0/0.25 and 0.0/0.0 both rank
the fresher row first), so a caller that only reads relative order is
unaffected here; a caller that reads, thresholds on, or logs the actual
HybridSearchResult score is affected even with every other signal off,
because 1.0 and 0.25 are not 0.0 and 0.0.
What to do. If your recency weight resolves to 0.0 — by omitting
recency_weight on a directly constructed store, by passing it explicitly, or
through a zero SearchConfig.default_recency_weight — and you also supply
a cognitive-cycle reference (an explicit current_cycle, or a configured
cycle_provider): re-check result order after upgrading on any query that
has heterogeneous thought priorities, another signal active, and falls
through to the FTS-inactive/vector-inactive fallback path — it may now favor
priority where it previously favored an unintended recency leak. Separately,
and regardless of whether another signal is active: re-check any code that
reads, thresholds on, or logs the raw fallback-path score itself. On 0.7 the
recency contribution to that score is always exactly 0.0 where it used
to leak a cycle-decayed value — but the score's total only drops to 0.0
when nothing else contributes to it (no other active signal, as above);
otherwise the total keeps whatever other contribution was already part of
it, unchanged, as the 0.85 → 0.6 stale row in the table shows: its
recency term went to 0.0, its 0.6 priority contribution did not move.
If you actually want cycle-decayed ranking, give the resolved
recency_weight a positive value instead of relying on the previous
behavior. If you use recency_now instead of current_cycle, none of this
applies to you.
Typed break: EngravaMetrics.schema_version moves from Literal[1] to
Literal[2]. No database migration is involved — this is the metrics
snapshot value-object returned by await store.metrics(), not the SQLite
core schema (PRAGMA user_version), which is unaffected by this change.
Why the version moved. EngravaMetrics gained a new field, measured: bool, so a caller can tell a genuinely empty, measured store apart from the
zero-filled placeholder metrics() returns when MetricsConfig.enabled is
False. Nothing else in the snapshot stated that either way: storage.db_bytes
reads 0 for a disabled store and for a measured in-memory one, and
search_latency.sample_count reads 0 for a disabled store and for a
measured store that has served no searches — see
Observability → Configuration for the
measured numbers behind both. Adding a field is a shape change, so the version
moves with it.
Who is affected. This reaches more callers than the Literal type alone
suggests:
Code that pattern-matches or asserts on
schema_version == 1(or theLiteral[1]annotation itself) breaks at type-check time or at runtime, whichever it does.Code that persists a serialized
EngravaMetricssnapshot — to a file, a message queue, a metrics store's own history — and later deserializes it expecting exactly the old field set is affected too, even though it never wrote or checkedschema_versionitself: a snapshot written before this upgrade has nomeasuredkey, and a snapshot written after does.A script that only shells out to
engrava info(orengrava --format json info) and parses its output is affected too, even though it never importsengravaor theEngravaMetricstype at all: the CLI's JSON gains themeasuredkey right along with the library object. Itsschema_versionkey does not simply move to2the same way — this release removes that key outright; see the next break below for what replaces it.Code that only reads individual fields (
m.thoughts.total,m.storage.db_bytes, etc.) without inspectingschema_versionis unaffected.A subclass of
EngravaMetricsthat appends its own field (nothing in the public API prevents this — the class is not sealed) shifts that field one slot further out in its own positional order. A positional call built against the subclass's previous field count now silently binds its last argument to the newmeasuredinstead of the subclass's own field, with no error either way.A keyword-based reconstructor or copy adapter — code that rebuilds an
EngravaMetricsfrom the six previously-named fields it knows about (EngravaMetrics(schema_version=..., snapshot_timestamp=..., thoughts=..., edges=..., storage=..., search_latency=...)) now silently producesmeasured=Falseon every rebuilt copy, including one built from a snapshot that was itself genuinely measured. Any such adapter that is meant to preserve a real measurement must be updated to carrymeasuredthrough explicitly.A whole-object validator that checks
asdict(snapshot)against an expected key set (e.g.assert set(asdict(m)) == {...}) now rejects every snapshot over the unexpectedmeasuredkey — without persisting anything, constructing positionally, checkingschema_version, or going through the CLI, so this is not the same population as any bullet above or below.
What to do. Update any Literal[1] annotation or schema_version == 1
check to 2. If you persist snapshots, branch on schema_version (or on the
presence of the measured key) when reading old records back, and prefer
checking measured over trusting an all-zero snapshot as a real reading —
see Observability → Configuration. If you
parse engrava --format json info output in a script, update it too: it
gains the new measured key, and — separately — its schema_version key is
gone; see the next break for what replaces it. Also
review any consumer that constructs EngravaMetrics positionally (directly
or through a subclass), depends on its field order, rebuilds one from named
keywords, validates its key set, or unpacks **asdict(snapshot) into a
function typed for the old field set — that last case raises an
unexpected-keyword error immediately rather than persisting or silently
accepting anything, so it is not the same population as the bullets above.
Typed break: engrava --format json info loses its schema_version key.
info used to build its JSON output from **asdict(metrics), so this key
was always EngravaMetrics.schema_version — the metrics snapshot's own
shape version — never the database's. An operator reading Schema version:
in the text output, or schema_version in the JSON, had every reason to
read it as the database's PRAGMA user_version; it was not. This release
fixes that instead of carrying it forward with a new measured key
alongside it.
What changed. engrava --format json info now reports
metrics_schema_version (what schema_version used to mean) and
database_schema_version (the database's stamped user_version — the same
number Schema-version checks gates on) as two
separate keys; schema_version itself is gone. The text output's Schema version: line is now Metrics schema version: 2 (database schema version: 21) — both numbers named and shown, never one number under an ambiguous
label.
Who is affected. Any script that parses engrava --format json info and
reads data["schema_version"] breaks with a KeyError immediately, which is
the least-bad failure mode here: it fails loudly rather than silently
comparing a metrics-snapshot version against a database-version expectation
(or vice versa). A script that only greps the text output for Schema version: also breaks, now finding Metrics schema version: instead. Code
that reads other info fields (thoughts, edges, storage,
search_latency, measured, db_path) is unaffected.
What to do. Update data["schema_version"] to data["metrics_schema_version"]
if that is what you meant, or to data["database_schema_version"] if you were
actually trying to read the database's own schema version — which is the
version engrava migrate acts on, not the metrics
snapshot's. Update any text-output grep for Schema version: to Metrics schema version:.
New exception types: WriteContentionError and WriteLockTimeoutError.
Neither class existed on 0.6.0; both are new EngravaError subclasses,
exported from the package root. No schema change is involved.
Who is affected. Anyone whose exception handling names specific engrava
exception types around one of the guarded write paths under real
concurrency — including a caller that also has a broader EngravaError
(or bare except Exception) handler in the same try, since which
handler actually runs depends on clause order, not on which one is more
specific (see below). The two new types fire in different circumstances and
are not interchangeable:
WriteContentionErrorcomes only from the dedup probe-and-insert window:create_thought(deduplicate=True),get_or_create,upsert_by_hash, andbulk_store(deduplicate=True)per row — sharing the same internal dedup-insert pathcreate_thought(deduplicate=True)uses, not a call to the publiccreate_thought()itself — a caller branching on the error'soperationfield sees"create_thought"frombulk_storetoo (that shared path's fixed label), never"bulk_store".bulk_store's whole batch runs inside one transaction, so only its first row opens this window; every later row in the same batch shares the window the first row already holds rather than opening its own. It is raised only afterPRAGMA busy_timeouthas already waited out ordinary contention on aBEGIN IMMEDIATEattempt and the store's own bounded retries with backoff are exhausted — so it signals genuine, sustained cross-connection contention, not a transient collision. On 0.6.x the same contention could instead produce a duplicate row (two connections both probing the content hash, both missing, and both inserting) or leak a rawsqlite3.OperationalError— a class outside theEngravaErrorhierarchy — depending on timing.WriteLockTimeoutErroris broader: it comes from the task-reentrant write lock now held across every guarded write path's read-validate-write-commit span, not only the four dedup methods above. It usually means a task was spawned and awaited from inside another task's ownsuspend_auto_commit()window — an out-of-contract deadlock this now ends by raising rather than hanging forever — but it can also mean the acquire-timeout bound is configured too small for a genuinely slow embedding provider, sincebulk_store's batch embedding call holds the lock for the whole, network-bound round trip. The two causes are not distinguishable from the exception alone.
Who is not affected. A store never used from more than one task or
connection at a time never exercises WriteContentionError's path — that
one requires genuine cross-connection contention. WriteLockTimeoutError is
not fully excluded by single-task use: write_lock_acquire_timeout_seconds
takes no validation, and with a non-positive configured value (0, or
negative) a task that does not already hold the write lock raises it
immediately, even with nothing else contending. A task that already holds the
lock re-enters it without waiting, so its call proceeds. See the
flush_access_buffer() reproduction later in this section for measured
numbers. A default or positive timeout does not raise it for a single task
on a single connection.
The population that matters most here has two shapes, and the fix differs
by shape. Before this release, sustained dedup contention surfaced as a
raw sqlite3.OperationalError, a class outside the EngravaError
hierarchy. A caller retrying on that specific type — except sqlite3.OperationalError: retry() around one of the four methods above —
was catching and retrying it directly.
A caller with only that
sqlite3.OperationalErrorhandler, and no broaderexcept EngravaErroror bareexcept Exceptionanywhere in the sametry: the retry stops firing, because the store now retries internally first and raises the unrelated-hierarchyWriteContentionErrorinstead, and nothing in this caller'strymatches it. This population's contention failure now propagates uncaught.A caller with both a broader handler —
except EngravaErroror a bareexcept Exception— and thesqlite3.OperationalErrorretry handler: Python triesexceptclauses in the order they are written and runs the first one that matches — not the most specific one.WriteContentionErroris both anEngravaErrorand anException, so whichever broader clause is written first intercepts it before thesqlite3.OperationalErrorclause below is ever reached. The retry does not raise and does not run uncaught — it silently never runs at all, and whatever the broader handler does runs in its place. This is the case our own advice, followed literally, would still leave broken if incomplete: adding aWriteContentionErrorhandler after an existing broader one changes nothing, because the earlier, broader clause still matches first — regardless of whether that broader clause namesEngravaErroror is a bareexcept Exception.
What changed. The dedup window now opens with BEGIN IMMEDIATE instead
of an implicit deferred transaction, so cross-connection contention surfaces
at the window's start rather than mid-transaction or not at all; and every
guarded write path now serialises across tasks on one lock per store
instance, with a bounded wait rather than an unbounded one.
What to do. Add an except WriteContentionError: handler around the
four methods above to retry contention, and place it before any broader
handler in the same try that could match it — a named except EngravaError, or a bare except Exception. Python runs the first
except clause that matches, not the most specific one, and
WriteContentionError is both an EngravaError and an Exception; either
one, written earlier, intercepts it and makes a later, more specific clause
dead code — there is no handler shape for which position stops mattering.
Separately: an existing except sqlite3.OperationalError: handler left
exactly where it is does not need reordering relative to a broader handler —
it no longer matches this failure at all (WriteContentionError is not a
sqlite3.OperationalError), so it now falls through to whatever broader
handler is present, in either order. Retrying on WriteContentionError is
safe outright — the transaction never started, so nothing was read or
written under it. A WriteLockTimeoutError from a store with a slow
embedding provider and large batches may just need a higher
write_lock_acquire_timeout_seconds; one from anywhere else is worth
checking for a task spawned and awaited from inside a suspend_auto_commit()
window on the same store instance — see
Concurrency.
Overriding create_thought() no longer runs on three insert paths — override
prepare_thought_for_insert() for that instead. No schema change.
What a 0.6 user gets. On 0.6.x, get_or_create()'s and
upsert_by_hash()'s miss branch, and each item in bulk_store()'s insert
loop, all called the virtual self.create_thought(...), so an override
sitting on top of the public method ran on all three as well as on direct
calls. Other paths that write a thought row, such as derived-record children,
never called it. On the released
0.7.0, none of the three do — get_or_create() / upsert_by_hash()'s miss
branch and bulk_store()'s per-item insert all reach internal primitives
directly, never the public create_thought() method, so a create_thought()
override now runs only on a direct create_thought() call (or through
remember(), which makes one). A hit on get_or_create()
was already routed through _increment_confirmation(), not create_thought(),
on 0.6.x, so it is not affected;
upsert_by_hash()'s hit branch calls the separately-overridable
update_thought() instead when a mutable field differs, under locks that
override can't safely nest into
— see Concurrency and
Extension hooks §1B.3
for what that means for an update_thought override.
What to do. Move validation or persisted enrichment out of a
create_thought() override and into prepare_thought_for_insert() — the one
override point that covers create_thought, get_or_create, upsert_by_hash,
bulk_store and remember uniformly, running before the decisive duplicate
probe and any row write (not before any probe — get_or_create() /
upsert_by_hash() run their own exploratory probe first), with no lock this
call itself holds. See
Extension hooks §1B for
the full contract, including its exact invocation count per entry point (a
stable get_or_create() / upsert_by_hash() hit costs zero seam calls,
consistent with it never having called create_thought() on 0.6.x either).
Keep only one canonical implementation — retaining both an old
create_thought() override and the new seam risks running your logic twice
on a direct create_thought() call.
A direct create_thought(deduplicate=True) call is not simply unaffected
either — a miss there used to run the override twice, and now runs it
once. On 0.6.x, create_thought(deduplicate=True)'s own miss
branch re-entered self.create_thought(thought, deduplicate=False) — the
same virtual call an override sits on top of — so a call arriving with
deduplicate=True reached the override once for the original call and once
more for that internal recursive re-dispatch. Executed directly, with a
metadata-stamping override recording each call's own deduplicate argument:
a single create_thought(deduplicate=True) miss produced [True, False] on
v0.6.0 — the override ran twice, each time appending to a persisted counter
that reached 2. On the released 0.7.0 it produces [True] — the override
runs once, and the same counter reaches 1. Anyone whose override is not
idempotent (increments a counter, appends to a log, emits a metric) sees that
effect halve on this specific call shape; anyone whose override is idempotent
(sets a fixed value) sees no observable difference here, only on the
get_or_create() / upsert_by_hash() / bulk_store() paths above.
Who is not affected. Anyone calling plain create_thought() — with
deduplicate omitted, False, or a hit under deduplicate=True — sees no
change: those paths call the override exactly as many times as before. A
subclass that does not override create_thought() has nothing to lose on any
path; migrating straight to prepare_thought_for_insert() (rather than first
adopting, then abandoning, a create_thought() override) has nothing to
migrate away from either.
A no-op upsert_by_hash() match now takes the cross-connection write
reservation when the connection has no transaction open; 0.6.0 took none.
No schema change. That is the general BEGIN IMMEDIATE change documented
above (see the WriteContentionError section), so a no-op upsert_by_hash()
match can now raise WriteContentionError under cross-connection contention
where 0.6.0 did not. The no-op branch writes nothing, so it rolls back only
a transaction its own probe opened and leaves any other open transaction
alone.
What to do. Nothing beyond the WriteContentionError handler described
above; it applies to a no-op match as to any other upsert_by_hash() call.
A dedup hit's journal entry can no longer be lost while its confirmation bump survives — no concurrency required. No schema change.
Who is affected. Anyone journaling writes (journal_enabled=True) who
relies on the journal for a complete audit trail, and who deduplicates via
create_thought(deduplicate=True) or get_or_create against existing
content — with or without any concurrency at all; a single task on a single
connection reaches this. _increment_confirmation() (the dedup-hit branch)
used to commit the confirmation_count bump on its own, then append to the
journal afterward with no further commit of its own. If the connection
closed (or anything else ended the session) before some later, unrelated
write happened to flush that pending journal insert, the confirmation bump
was durable and its UPDATE_THOUGHT journal entry was not. Executed
directly: a dedup hit followed immediately by closing the connection left
confirmation_count bumped but only the original INSERT_THOUGHT entry in
journal_entry on 0.6.x-shaped code; 0.7.0 left both the bump
and its UPDATE_THOUGHT entry.
Who is not affected. Anyone not journaling (journal_enabled=False,
the default) — there is no journal entry to lose either way. A dedup hit
followed by any other guarded write on the same store before the connection
closes was also fine on 0.6.x: that later write's own commit flushed the
still-pending journal insert along with it.
What changed. _increment_confirmation() now performs the UPDATE, the
read-back, and the journal append, then commits once at the end — the same
single-commit-per-write-path rule this release applies elsewhere (see
update_thought, upsert_by_hash, and the other guarded write paths). The
row and its audit entry are durable together whenever _increment_confirmation()
itself decides when to commit. It does not decide that inside a caller-owned
suspend_auto_commit() window, and the pair can still come apart there. If
the journal append raises — a trigger rejecting the insert is the concrete
case — and the caller's own code catches that failure before the window's
body returns, the bump is never rolled back: _increment_confirmation()'s own
_maybe_commit() is a no-op inside the window, but the window's own clean
exit commits the transaction anyway, bump included, with no journal entry for
it. Executed directly, on both 0.6.x-shaped code and 0.7.0: a
dedup hit inside suspend_auto_commit(), with a trigger rejecting the
journal insert and the surrounding code catching that error before the block
exits, left confirmation_count bumped and committed with no UPDATE_THOUGHT
entry for it, on both versions. That change closes the single-connection,
no-suspend_auto_commit gap described above; it does not make the pair
atomic against a failure the caller catches inside its own deferred-commit
window.
What to do. If you journal and deduplicate, nothing to change — this is
a fix, not a new obligation. If you have tooling that reconciled a
0.6.x journal against confirmation_count values and treated a missing
UPDATE_THOUGHT entry after a dedup hit as evidence of corruption or
tampering, it was more likely this gap; that reconciliation logic no longer
needs to special-case it going forward. If you deduplicate inside your own
suspend_auto_commit() window, do not catch a failure from that call inside
the window if the bump and its journal entry must stay paired — let it
propagate so the window's own rollback undoes the bump too.
suspend_auto_commit() now rolls back on cancellation, and a nested
window commits and rolls back only at the outermost exit. No schema
change. These are two separate fixes to the same context manager, and the
first one reaches plain, non-nested callers — including an ordinary
bulk_store() call — not only nested usage.
Who is affected. Two groups:
Anyone whose
suspend_auto_commit()block can be cancelled — nested or not. This includes a single, non-nestedbulk_store()call driven underasyncio.wait_for, a timeout wrapper, or a task group that cancels siblings on one member's failure.Code that opens a nested
suspend_auto_commit()window on the same task — one batch method calling into another that also wraps its writes insuspend_auto_commit(), on the same store instance — and relies on the inner call's own exit to commit or roll back anything.
Who is not affected. A single, non-nested suspend_auto_commit() block
that runs to completion, or raises an ordinary Exception, sees no change:
it always committed on clean exit and rolled back on an ordinary exception,
and still does. It is not unaffected under any BaseException that is not
an Exception — cancellation is the practical case (see below), but
SystemExit and KeyboardInterrupt share the identical gap, for the
identical reason (except Exception on 0.6.x, except BaseException on
0.7). Executed directly: a single, non-nested block that ran one write and
then raised SystemExit left the write neither committed nor rolled back on
0.6.x-shaped code (in_transaction stuck True, the row visible only
within that still-open transaction); the identical block on 0.7.0
rolled the write back cleanly.
What changed. On 0.6.x, suspend_auto_commit() caught only Exception.
asyncio.CancelledError does not derive from Exception, so a cancellation
landing inside the block — nested or not — skipped both the except and the
else branch entirely: no rollback ran, _skip_auto_commit was still reset
to False in finally, and the transaction was left open (in_transaction
stuck True) rather than resolved either way. The finally reset was not
conditioned on a clean exit either: it ran whether the block below it
returned normally or raised anything at all — including an ordinary
exception the caller's own code went on to catch outside suspend_auto_commit
itself. So a nested inner call whose ordinary exception the outer body
catches still reset auto-commit for the rest of the outer block on its way
out, letting a subsequent outer write commit immediately and standalone,
ahead of anything that happened later — a cancellation included. Layered on
top of that, a clean inner exit made a separate mistake: its own else
branch committed the whole (still-open, outer-owned) transaction early,
regardless of nesting. On 0.7, the block catches BaseException around the
yielded body, and nesting is tracked by a depth counter rather than a flag:
only the outermost call's own exit from that body — clean, an ordinary
exception, or a cancellation of the body — decides whether to commit or roll
back, unconditionally and at any nesting depth; an inner call's own exit, of
any kind, only decrements the depth and touches no transaction state. This
covers a cancellation of the block's own work, not a cancellation of the
commit that follows it. The outermost call's own commit() runs in the
else branch, after the guarded body has already returned — outside the
except BaseException that protects the body — on both 0.6.x and 0.7 alike.
Executed directly: cancelling right as that commit() call is completing
leaves one committed row with in_transaction already False, on both
versions; this method has never rolled back a cancellation that lands in
that specific window.
What to do. Do not carry over an assumption from 0.6.x about what a
nested batch left committed after an inner failure or an outer cancellation
— on 0.6.x this depended on exactly where in the nested sequence the failure
landed and on the kind of exit each inner call took, not only on whether
the whole batch ultimately raised. On 0.7, a nested suspend_auto_commit()
batch is atomic at the outermost boundary only for a failure that actually
escapes the outermost call's own body — propagates all the way out of
every nested level without being caught by intervening application code —
in which case nothing commits and the whole batch rolls back, regardless of
how deep the failure originated. An inner exception or cancellation that
your own code catches before it reaches the outermost body does not roll
anything back: the outermost body still exits cleanly and still commits
everything written so far, inner rows included. Executed directly: an inner
cancellation caught by the outer body's own try/except, with the outer
body then continuing to its own clean exit, left both the inner row and the
outer row committed. This also does not cover a cancellation landing after
the body finishes, while the outermost call's own final commit() is in
flight — that narrow window is unguarded on both 0.6.x and 0.7, and a
cancellation there can still leave the batch committed. Re-verify any code
that inspects or manually finishes a connection's transaction state, or that
assumes a caught inner failure implies a rollback, against this rule rather
than against 0.6.x's behavior.
A bulk_store() call nested inside a caller's own suspend_auto_commit()
window silently drops automatic derivation. No schema change.
Who is affected. Anyone with the derived-records seam enabled
(DeriveGates(enabled=True)) who calls bulk_store() from inside their own,
already-open suspend_auto_commit() window on the same store instance.
bulk_store() dispatches derivation itself, locally, right after its own
inner suspend_auto_commit() block exits — gated on self._skip_auto_commit
being False, meaning "durable now." On 0.6.x, any suspend_auto_commit
exit — inner or outer — reset that flag to False unconditionally, so by
the time bulk_store()'s own dispatch loop ran, the flag already read
False even though the caller's outer window was still open, and dispatch
proceeded. On 0.7, the flag is a depth counter that only reaches 0 when
the outermost window closes, so with an outer window still open,
bulk_store()'s dispatch loop correctly sees itself as "not yet durable"
and skips every dispatch in the loop — and nothing ever retries them:
there is no second pass when the outer window eventually closes. Executed
directly with a real structural-split producer: a single-thought
bulk_store() call nested inside an outer suspend_auto_commit() window
produced the source thought plus 2 derived children and 2 DERIVED_FROM
edges on 0.6.x-shaped code; on 0.7.0, only the source thought
persisted — no children, no edges, and no error of any kind.
Who is not affected. A bulk_store() call made directly, with no
suspend_auto_commit() window open anywhere in the same task's call stack,
dispatches derivation normally on both versions — this is bulk_store()'s
ordinary, documented case. "Not wrapped in an explicit
suspend_auto_commit() call" is not the same condition, and undercounts the
affected population: bulk_store() always opens its own window
internally, and an on_store hook fires inside that window on each row —
so a hook that reacts to a stored thought by calling bulk_store() again is
nesting, exactly as if the caller had wrapped it explicitly, even though the
hook's own source never mentions suspend_auto_commit(). Executed directly,
with a real structural-split producer: a direct, non-nested bulk_store()
call for a multi-paragraph thought derived 3 children as expected; the
identical call issued from inside an on_store hook fired by an outer
bulk_store() call for a different thought derived 0. A store with the
derived-records seam disabled is unaffected either way, since dispatch is a
no-op regardless of nesting.
What changed. This is a side effect of the same depth-counter fix
documented above: correctly deferring "durable now" to the outermost
window's close, for a call path (bulk_store's own post-exit dispatch
loop) that was never updated to defer its dispatch to that same outermost
close instead of its own.
What to do. Do not call bulk_store() from inside your own
suspend_auto_commit() window if you need its automatic derivation — call
it outside any such window, where its own inner window is the outermost one
and dispatch fires normally. If you already have code that does this
nested, its derived records are silently missing on 0.7 with no exception
raised; find every newly-created thought from such a call and drive
derivation for it explicitly via derive_existing(), the documented
backfill entry point.
gc --dry-run understated what the real run deletes. No schema change.
Who is affected. Anyone who ran gc --dry-run before an actual gc and
used its message to decide whether to proceed. If you approved a gc run
on the strength of the old dry-run wording, you approved deleting more than
it showed you.
Who is not affected. What gc actually deletes has not changed — only
the dry-run and --help text describing it.
What changed. The dry run and the command's own help text described gc
as removing archived thoughts and their orphaned edges. The real run has
always done more: it deletes every edge touching an archived thought
(orphaned or not), that thought's embeddings and its actions, then
reconciles the vector index. The dry run now says so — "Would delete N
archived thoughts, plus their edges, embeddings, and actions" — matching the
rest of the documentation. The destructive run's own output did not
change and still does not say this: run against a database with one
archived thought carrying an edge, an embedding, and an action, it prints
exactly Collected 1 archived thoughts. — nothing about the edge, the
embedding, or the action it also deleted. Only the dry run and the prose
documentation describe the full set; the real command's own output still
reports just the thought count.
What to do. Re-read CLI reference → gc before your next
collection if edges, embeddings, or actions on an archived thought matter to
you independently of the thought itself — the old dry run would not have
told you they were going.
Reconciliation, the vector-index purge and search now resolve a vector through
its thought row. No schema change of its own, but it interacts with whether
your database has run the core-12 migration.
Who is affected. Anyone running a database that has not run engrava migrate past core schema 12, with a sqlite-vec backend active. On such a
database on 0.6.0, deleting a thought (via delete_thought, the TTL
delete strategy, or hygiene GC) purged the thought's own vector immediately,
but the reconcile pass that runs on the next sqlite-vec-enabled open could read
the still-present embedding row as proof the thought was live and put the
vector back — after which the deleted id could reappear as an ordinary
search-similar candidate. Separately, and regardless of schema version:
gc and restore now refuse outright and exit 1 on any database that is
not exactly on this build's head schema version — but the recovery action
differs by direction. Below head, the refusal names engrava migrate.
Above head — a database written by a newer engrava — engrava migrate
cannot help and refuses too (SchemaVersionError's "newer_than_head"
reason); the refusal instead says to upgrade engrava itself before opening
that database. If a gc that used to run cleanly on an unmigrated
database now exits non-zero, that is this refusal working as intended, not
a regression in what it collects. Read commands (info, verify,
export, snapshot, a query that parses as FIND/COUNT/SELECT)
still run on a behind schema, but now warn on stderr rather than staying
silent about it; above head, a read command refuses exactly like a
destructive one.
Who is not affected. A database already migrated to core-12 or later
avoided the resurrection path only when the deleting connection also had
PRAGMA foreign_keys=ON — the cascade that removes the embedding row is a
per-connection setting, not a property of the schema file. from_config()
sets it explicitly, and so does an ordinary, transaction-free call to
ensure_schema(), including a no-op call against an already-current
schema — but PRAGMA foreign_keys is itself a no-op inside an open
transaction, so an ensure_schema() call made on a connection that already
has one open leaves enforcement exactly as it was, silently. A caller
who instead constructs SqliteEngravaCore(conn) directly and skips
ensure_schema() on that connection — plausible against a file a different
process already migrated — inherits SQLite's foreign-keys-off default, and
the old, cascade-reliant delete_thought() left the embedding row behind
there exactly as it would below core-12. Executed both ways at schema 20: a
thought and its embedding created and migrated on one connection, then
deleted with a bare DELETE FROM thought on a second connection that never
called ensure_schema(), left the embedding row in place (remaining=1,
foreign_keys=0); the identical delete on a connection that had called
ensure_schema() (or used from_config()) removed it (remaining=0,
foreign_keys=1). Independently of schema version or FK state: a store with
no sqlite-vec backend active was never able to serve the vector arm that
made the id reappear. Read commands against an already-current schema see no
new warning; destructive commands against one see no new refusal.
What changed. Reconciliation (sync_embeddings) now backfills a vector
only for an embedding row whose owner id matches a row in thought, and
the vector-index purge and search's own eligibility check require that
thought row too. delete_thought (and the TTL and hygiene delete paths)
now issue their own deletes for the thought's edge, embedding, and
action rows, in the same savepoint as the parent delete, which runs
first, rather than depending on the core-12 ON DELETE CASCADE. Schema
version checks are also new: destructive commands, remember and link
refuse a database outside the head version, read commands warn below it and
refuse above it (recall with --config refuses below it too), and the new
SchemaVersionError (exported from the package root) is what ensure_schema(),
from_config(), EngravaManager.get_store(), and engrava migrate raise
when a database is a populated schema below the bootstrap floor, an empty
but older-shaped schema below that same floor, or stamped above this
build's head version.
What to do. Run engrava migrate if you have not already, on any
database below head that you plan to run gc or restore against. If the
refusal instead says the database is newer than this build's head
version, engrava migrate will not resolve it — that database was written
by a newer engrava, and the fix is to upgrade engrava, not to migrate. If a
database accumulated dangling embedding rows from deletions made
under an older engrava build, on a schema still below core-12:
engrava migrate purges them as part of the core-12 step. See
Known Limitations → Deletion on a database that has not
been migrated
for the full mechanism.
ConnectionQuarantinedError is now reachable from a plain delete_thought()
call, with the derived-records seam disabled. No schema change.
Who is affected. Anyone whose delete_thought() (or the TTL delete
strategy, or hygiene GC) hits any failure the store cannot cleanly unwind
while deleting a thought's edge / embedding / action rows — not only a
cancellation. Two distinct, independently-executed triggers both reach the
same outcome:
A cancellation landing during the savepoint's
RELEASE. aiosqlite's worker thread can complete thatRELEASEon the connection even though the awaiting coroutine observes a cancellation instead of the release's success; the store's own unwind (ROLLBACK TOthe savepoint) then fails because the savepoint it targets has already been consumed.An ordinary, non-cancellation failure that also leaves the unwind unable to complete — no cancellation and no derivation involved at all. Executed directly: an
ON DELETEtrigger that vetoes the edge delete withRAISE(ABORT, ...)makes the initial delete fail withIntegrityError. A trigger fires on table operations, not on a transaction-control statement, so it cannot itself deny the subsequentROLLBACK TO— the mechanism that can is an authorizer callback (set_authorizer, onsqlite3.Connectionand onaiosqlite's wrapper of the same hook) refusing that statement. Executed directly: the denial surfaces asDatabaseError("not authorized"). Either way, the store cannot prove the deletes were undone.delete_thoughtraises the originalIntegrityError, exactly as it should — and the next guarded call on that instance raisesConnectionQuarantinedError.
Both cases produce ConnectionQuarantinedError on the next guarded call,
not on the delete_thought() call itself (which raises whatever the
original failure was). On 0.6.0, ConnectionQuarantinedError was
reachable only through a failed derived-record compensation rollback — a
caller who never enabled that seam had a real reason to treat this error as
something that could not happen to them.
Who is not affected. A delete_thought() (or TTL delete, or hygiene GC)
call whose child-row savepoint unwind either never has to run (nothing
failed) or runs and completes cleanly never reaches this path, seam enabled
or not.
What changed. delete_thought (and the TTL delete strategy, and hygiene
GC) gained the explicit child-row deletion described above, wrapped in a
SAVEPOINT whose release sits inside the guarded region, so a failure or
cancellation delivered while awaiting it reaches the store's unwind. When
that unwind attempt fails — through a race with a cancellation, or an
ordinary error that also defeats the ROLLBACK TO — it quarantines the
connection rather than guessing at a consistent state, as the
derived-record compensation path does. The two paths (derivation
compensation, and this one) now share one outcome (ConnectionQuarantinedError
on the next guarded call) from independent triggers.
What to do. Treat ConnectionQuarantinedError as reachable from any
delete_thought(), TTL-delete, or hygiene-GC call whose child-row deletes
can fail to unwind cleanly — whether from a cancellation racing the
savepoint release, an installed trigger's delete veto paired with an
authorizer denying the follow-on ROLLBACK TO, or any other failure that
leaves the ROLLBACK TO itself unable to complete — not only from
derived-record-enabled stores. A trigger veto alone is not enough: it fails
the initial delete, but ROLLBACK TO still succeeds unless something else
— the authorizer, or another unwind failure — also blocks it. On catching
ConnectionQuarantinedError, replace the store instance — it is terminal
by design, the same as before this change.
Child deletion stays atomic with the parent delete. No schema change.
On 0.6.0, delete_thought() was one DELETE FROM thought. On 0.7.0
the store deletes the children explicitly (see the entry above on deleted
vectors): the parent delete and the child deletes run inside one savepoint,
which is rolled back when the parent delete did not remove a row that
existed.
Who is affected. Anyone whose delete-time policy is a thought-table
trigger. What changed below lists the differences from 0.6.0.
Who is not affected. A database with nothing installed on the thought
table that can fail, skip, or stop matching the parent delete never
exercises any of this, on 0.6.0 or 0.7.0 alike.
What changed, relative to 0.6.0.
Ordering. A
BEFORE DELETE ON thoughttrigger — including aWHEN EXISTS (...)guard that reads a child row — sees the children present when it fires, exactly as on0.6.0: the parent delete, and the explicit child deletes that now follow it inside the same savepoint, have not run yet.The enforcement-off sweep.
delete_thought(and the TTLdeletestrategy, and hygiene GC) issue their own deletes foredge,embedding, andactionrows rather than depending on the core-12ON DELETE CASCADE, as the entry above on deleted vectors describes: on0.6.0, a connection withoutPRAGMA foreign_keys=ON, or a database below core-12, left these rows orphaned behind a deleted thought.What
RAISE(IGNORE)does. ABEFORE DELETE ON thought BEGIN SELECT RAISE(IGNORE); ENDtrigger silently suppresses the parent delete — no exception,delete_thought() == False— exactly as it would against0.6.0's single statement, and0.7.0reaches the same outcome for the children: the store checks, inside the same savepoint and immediately before the parentDELETE, whether the row existed at all. If it did, a delete that still matched zero rows is not treated as "already gone" — the savepoint is rolled back instead of running the child sweep, so the still-live parent keeps its children. A genuinely nonexistentthought_idtakes the other branch: the child deletes run for that id, removing the orphanededge,embedding, andactionrows a schema without a cascade may have left for it. Verified againstRAISE(ABORT),RAISE(FAIL),RAISE(IGNORE), and aWHEN EXISTSguard, withPRAGMA foreign_keysboth on and off, throughdelete_thought(),cleanup_expired()'sdeletestrategy, and hygiene GC.TTL cleanup stops acting on a delete that did not happen. The
deletestrategy ofcleanup_expired()now checks the parent delete's own outcome before purging the thought's vector or appending aDELETE_THOUGHTjournal entry, so a suppressed parent delete no longer purges a live vector or records journal history for a thought that is still there;0.6.0did not check that outcome before either step. One inaccuracy is unchanged from0.6.0:expired_countis not the number of rows actually removed, so it does not discriminate a suppressed delete from a successful one. Checkdelete_thought()'s own return value, not the TTL count, when that distinction matters.An unconditional
RAISE(ROLLBACK, ...)trigger still ends the whole surrounding transaction rather than just this delete, as on0.6.0. It protects the children when it fires, at the same cost as always: an unrelated write earlier in the same transaction does not survive either. Do not wrap the call in your ownSAVEPOINTexpecting to rescue that unrelated work — the trigger's own rollback removes your savepoint before yourexceptblock can use it.
What to do. If delete-time policy is installed as a
thought-table trigger: RAISE(ABORT), RAISE(FAIL), RAISE(IGNORE), and
a WHEN EXISTS guard are all handled correctly by the store and need no
caller-side workaround. An unconditional RAISE(ROLLBACK, ...) still trades
away the whole transaction when it fires, so prefer one of the other three
forms, or move the check earlier — before calling delete_thought() at all
— if that cost is not acceptable.
Connection cleanup on failure and cancellation is fixed — not only in the
CLI. No schema change. This reaches every caller of the library, not just
the CLI: SqliteEngravaCore.from_config(), EngravaManager.get_store(),
close(), and close_all() all changed, and the change is not limited to
corrupt-file opens.
Who is affected. The real axis is not "shutdown", and not cancellation
either — it is whether a failure during construction or close reaches a
cleanup that runs to completion and preserves the original error, whether
or not a cancellation is involved — with one residual limit: a second
cancellation delivered to _close_quietly while it is already draining a
first one can still abandon the close before it completes (see "What
changed" below). The four cases below all involve an ordinary Exception
cleanup failure, or at most a single cancellation, and are unaffected by
that residual:
Anyone who has pointed (or might point) an engrava CLI command at a corrupt or truncated database file. This bug shipped in 0.5.0:
_open_dbopened the connection and ran its setupPRAGMAs with no exception handling at all, so aDatabaseErrorfrom a corrupt file raised while the code that would have closed the connection sat only on the success path, and aiosqlite's connection-worker thread is not a daemon, so the interpreter could not exit — the command printed nothing and never returned. A directfrom_config()orEngravaManager.get_store()call did not hang on this: atv0.6.0, both already wrapped construction inexcept Exception: await db.close(); raise, so the same plainDatabaseErrorwas already caught, the connection already closed, and the error already propagated.Anyone whose process can be cancelled while closing a store — including one backed by a perfectly healthy database.
close()flushes the access-tracking buffer before closing the connection; a cancellation landing during that flush used to propagate immediately, skipping the connection close below it and leaking the same non-daemon worker thread as the corrupt-file case, on a database with nothing wrong with it.EngravaManager.close_all()had the matching gap across multiple stores: a cancellation while closing one store used to abandon the rest, worker threads and all, instead of finishing their closes first.Anyone whose
from_config()orget_store()call can be cancelled during construction — on a perfectly healthy database, with nothing to do with shutdown. A timeout wrapped around either call (asyncio.wait_for, a cancelled parent task) can land after the connection opens but before construction finishes. Executed directly against both versions: a cancellation raised mid-construction leaves the connection open —close()never reached — on 0.6.x-shaped code (except Exception, which does not matchasyncio.CancelledError); 0.7.0 (except BaseException) reachesclose()before re-raising the cancellation.Anyone whose
from_config()construction fails with an ordinary error whose own cleanup also fails — no cancellation anywhere in it. On 0.6.x, construction'sexcept Exception: await db.close(); raiselet a failure indb.close()itself replace the original error, because the bareraiseis never reached when the statement before it raises. 0.7.0 routes that close through_close_quietly, which swallows (and logs) its own failure so the original error'sraiseis always reached. Executed directly: construction raisingValueErrorwith cleanup raisingOSErrorsurfaced theOSErroron 0.6.x-shaped code and the originalValueErroron 0.7.0. A caller whose error handling branches on which exception type it catches, for this exact combination, sees a different type now — on a perfectly healthy database, with no cancellation involved at all.
Who is not affected. A process whose database file is healthy
(non-corrupt, non-truncated), whose store lifecycle — construction and
close, whether one store directly or several through EngravaManager — is
never reached by a cancellation, and whose cleanup never itself fails
during a construction error, sees no behavior change on that path.
What changed. Opening a connection (in the CLI, in from_config(), and
in EngravaManager's service construction) and entering the block that
guarantees its close are now one step, so a failure while opening —
including the sqlite3.DatabaseError a corrupt or truncated file raises, or
an asyncio.CancelledError reaching that same span — always reaches the
close instead of leaking an unclosed connection. close() now defers a
cancellation arriving during the access-buffer flush until the connection is
actually closed, rather than letting it skip the close. close_all()
finishes closing every remaining store when one of them is cancelled,
re-raising the cancellation only afterward. Cleanup is also routed through a
shared _close_quietly helper that catches Exception and logs it rather
than raising, so an ordinary close failure during cleanup never replaces
the original error that triggered the cleanup — on any construction
failure, cancelled or not. A single cancellation landing
during _close_quietly's own await conn.close() — the same suspension
point this whole change is protecting — no longer aborts the close.
The close now runs as its own task, shielded from that first
cancellation; on catching it, _close_quietly explicitly awaits the same
task again — unshielded, but a second throw into that await only happens
on an explicit second cancel() — so the close is held open until it has
actually completed before the cancellation is allowed to propagate. A
second cancellation delivered while that second await is in flight is not
covered: an unshielded await on a task propagates the awaiting
coroutine's own cancellation into that task, so this one genuinely
interrupts conn.close() mid-flight, abandons the close, and replaces
whatever error — including a construction failure the cleanup exists to
protect — is currently propagating with this new CancelledError. The
residual is narrower than before, not gone: it now takes two
cancellations arriving in that specific window, not one, and it is
tracked separately, not in scope for this note.
What to do. Nothing, unless a script wrapped CLI invocations in its own
timeout-and-kill logic to work around the old hang — that workaround is no
longer necessary, though it remains harmless. A script that inspected the
process's exit code will now see an immediate non-zero exit instead of an
indefinite hang. A library caller that wraps from_config(), get_store(),
or a store's close() / close_all() in a timeout, or whose surrounding
task can otherwise be cancelled during any of those calls, no longer needs
its own workaround for a leaked connection worker in the ordinary case —
construction and close alike now close the connection before a single
cancellation propagates. One window survives: a second cancellation
delivered to the cleanup's own await conn.close() while it is already
draining a first one, described above, still escapes before that close
completes, so the worker can leak there. The exposure is narrowed to that
double-cancellation window, not removed; reaching it takes two independent
cancellation requests landing on the same task, not one. Neither
TaskGroup nor asyncio.timeout() does that on its own — verified by
reading both directly: Timeout._on_timeout() calls task.cancel() from a
single scheduled callback with no reschedule, and TaskGroup._abort()
cancels each child once and then guards itself on its own _aborting
flag, never calling cancel() again on a task it has already aborted. A
task that swallows that one cancellation and keeps running is left
running, not cancelled a second time — confirmed directly: made to
swallow it, a task under either just hangs past the deadline instead of
being cancelled again. What would actually reach this window is a caller
that itself issues a second, independent cancel() on the same task — a
manual cancel-then-force-cancel escalation is the shape — but no call
site in this codebase does that, so no example is given here; verify that
any caller you have in mind genuinely calls cancel() twice before
assuming it is exposed. If your
error handling for a from_config() failure branches on the type of the
raised exception, re-check it against the original construction error, not
whatever a coincidentally-failing cleanup used to surface in its place.
Three exception classes are now importable from the package root.
ReferentialIntegrityError, DuplicateEdgeError, and CoreMigrationError
no longer require from engrava.domain.exceptions import ...; they import
directly from engrava, like every other exception in the reference table.
The troubleshooting entry documenting the old import failure is gone along
with it.
A hook that calls back into dedup no longer deadlocks — a resolved defect, in the reader's favour. No schema change.
Who is affected. Anyone whose on_store hook (or anything else reached
during the insert pipeline of create_thought(deduplicate=True),
get_or_create(), or upsert_by_hash()) itself awaits one of those same
three methods on the same store instance, from the same task — a hook that
records its own enrichment as a separately deduplicated thought is the
concrete shape. On 0.6.x, _dedup_lock — a plain, non-reentrant
asyncio.Lock — was held for the whole outer call, insert pipeline and
on_store included, because the miss branch of all three methods recursed
back into the public create_thought() from inside that lock's own async with block. A same-task re-entry into any of the three then blocked on a
lock the same task already held and could never release first — an
unconditional hang, not a race.
Executed directly, guarded by a bounded wait so the hang could be observed
rather than actually blocking forever: a hook that, on a miss, awaits
get_or_create() against different, already-existing content (so the
inner call is a hit and does not recurse into the hook again) stalled past
the bound on each of the three entry points — create_thought (deduplicate=True), get_or_create(), and upsert_by_hash() — on v0.6.0.
The identical setup completed normally, on all three entry points, on
0.7.0.
A spawned-and-awaited callback on a different task is also fixed. On
0.6.x, a callback into one of the three entry points from a task the hook
spawns and awaits hit _dedup_lock for the same reason the same-task case
did. Executed directly, guarded by the same
bounded wait as above: a hook that spawns a task to await get_or_create()
against different, already-existing content and then awaits that task stalled
past the bound on all three entry points — create_thought(deduplicate=True),
get_or_create(), and upsert_by_hash() — on v0.6.0, and completed
normally on all three on 0.7.0.
Who is not affected. A hook that reaches none of the three dedup entry
points again on the same store instance — directly, or through a wrapper such
as bulk_store(deduplicate=True) — did not reach _dedup_lock a second time,
so it did not hang on that lock on 0.6.x, whether the callback runs on the
same task or one it spawns and awaits. Such a hook can
still fail on 0.7.0, on a different lock. _write_lock guards writes
beyond the three dedup methods, and a spawned task gets none of the same-task
reentrancy that closes the gap above: a hook that spawns a task to call plain
create_thought() — not touching create_thought(deduplicate=True),
get_or_create(), or upsert_by_hash() — from inside an enclosing
suspend_auto_commit() window raises WriteLockTimeoutError on 0.7.0, the
same residual mechanism the next paragraph describes. Executed directly: that
hook, armed inside an open suspend_auto_commit() window with a short
write_lock_acquire_timeout_seconds, raised WriteLockTimeoutError rather
than completing or hanging past the bound.
A residual write-lock risk remains under an enclosing lock-holding
window. Outside such a window, both _dedup_lock and _write_lock are
released before on_store runs (see "What changed" below), which is why the
spawned-task shape above completes on 0.7.0. The residual risk is that the
calling task already holds _write_lock across the whole call — the
concrete case is an outer suspend_auto_commit() window — because a task
spawned and awaited from inside that window cannot reuse its parent's
task-reentrant grant, and blocks on the lock its parent task is holding.
Executed directly on 0.7.0: the same spawn-and-await hook, driven from
inside an open suspend_auto_commit() window, raised WriteLockTimeoutError
instead of completing — the task-reentrant write-lock hazard documented
elsewhere in this file. A non-positive write_lock_acquire_timeout_seconds
(0, or negative; the setting takes no validation) is a separate risk: a task
that does not already hold the write lock raises WriteLockTimeoutError
immediately, even against a free lock, with no enclosing window required — the
same gap documented above and in the flush_access_buffer() reproduction
later in this section.
What changed. Serialising the dedup probe-and-insert window narrowed
_dedup_lock's scope to end where the probe-and-insert span ends, before
_finish_create_thought() — and therefore on_store — runs, rather than
wrapping the entire call. This was not aimed at reentrancy; it is a side
effect of the same restructuring documented above (the miss branch calling
the internal insertion step directly instead of recursing through the
public method), which happened to move on_store outside the lock's scope
entirely.
What to do. Nothing — this is a fix, not a new obligation. If you previously worked around the hang (routing such a hook's dedup call through a separate store instance, or avoiding the recursive call shape entirely), the workaround is no longer necessary, though it remains harmless. If a test or monitor was built around detecting this specific hang (a timeout that was expected to fire), it will no longer fire for this reason.
flush_access_buffer() now takes the store's write lock, so it can wait for
it and raise WriteLockTimeoutError. No schema change.
Who is affected. Anyone with access tracking enabled
(access_tracking_enabled=True) whose flush_access_buffer() call —
explicit, or the automatic one at a consolidation cycle boundary or on
close() — can run while a different task holds the store's write lock. In
v0.6.0 the flush took no lock. It now waits for the lock and raises
WriteLockTimeoutError when it cannot take it within
write_lock_acquire_timeout_seconds. A flush with nothing buffered returns 0
without asking for the lock. Otherwise a non-positive value (0, or negative)
raises at once, even against a free lock; a task that already holds the lock
re-enters it without waiting and does not raise.
What changed. The flush drains the buffer only after it holds the lock, so
a call that raises WriteLockTimeoutError has taken nothing out of the buffer.
Executed directly on 0.7.0: one access event buffered, a different
task holding the write lock open (via its own suspend_auto_commit() window)
for longer than a write_lock_acquire_timeout_seconds of 0.2, then
flush_access_buffer() called — it raised WriteLockTimeoutError and the
event was still buffered; once the window closed, a retry returned 1 and the
row's access_count was 1. With write_lock_acquire_timeout_seconds=0, one
buffered event and no other task, two consecutive flush_access_buffer()
calls each raised WriteLockTimeoutError, the event was still buffered, and
the persisted access_count stayed 0; the identical setup with 0.001
returned 1 and persisted the count.
What to do. Retry the flush when it raises WriteLockTimeoutError, and
raise write_lock_acquire_timeout_seconds if access tracking runs on a store
whose other tasks hold long suspend_auto_commit() windows. close() catches
and logs a flush failure, so a close() whose flush times out on the lock
writes nothing.
restore now refuses an embedding row that is not a valid domain record —
not only one whose model_name/dimension disagree with the target. No
schema change.
Who is affected. Almost no one. Restore now constructs the same
EmbeddingRecord store_embedding() has always built, so it also enforces
that record's own constraints: a non-empty owner_type, a non-empty
owner_id, an ISO-8601 created_at, and a dimension greater than zero.
Every one of those constraints already existed in the domain model before
this change — store_embedding() was never able to write a row violating
them. The only way a snapshot can carry a row that fails one of them is if it
already came from a database holding one, and the only way a database can
hold one is if some earlier restore inserted it without validation — which
is exactly the gap this release closes. Checked directly against every
tagged release v0.2.0 through v0.6.0: none of them validates an
embedding row's content on restore, only its column types, so any of them
could have written such a row from a sufficiently adversarial or corrupted
input snapshot, and snapshot re-exports whatever is stored without
re-validating it — so the defect propagates forward through repeated
restore/snapshot cycles once introduced. A snapshot produced from a database
that has only ever been written through store_embedding() / create_thought()
never contains such a row, on any released version.
Who is not affected. Anyone restoring a snapshot whose embedding rows
came from normal use — the vast majority. This does not reject a valid
snapshot from an older release; every embedding row a supported write path
can produce already satisfies these constraints today and always has.
What changed. Restore validated an incoming embedding row's
dimension against its own vector_blob length before this release, but
inserted the rest of the row (owner_type, owner_id, created_at) as
whatever the snapshot declared, typed but not otherwise checked. It now
rejects the same four defects store_embedding() has always rejected.
What to do. If a restore now fails naming an invalid embedding record,
the flagged row was already invalid — not something this release broke.
Inspect it with --skip-embeddings (import everything else, then re-embed
the affected thoughts yourself) or --re-embed (regenerate every vector
from the target's own provider) instead of the plain vectors; either avoids
importing the bad row at all. If you must recover the original vector
first, edit the offending line in the snapshot file directly — it is plain
JSONL — before restoring.
The entries above are not a complete inventory of what changed.
A reader whose store is subclassed, hooked, wrapped in suspend_auto_commit(),
or driven from more than one task in a combination not covered above should
test that exact combination directly — treating its absence from this list
as a clean bill is not a conclusion this section supports.
A merge restore into a journalled target can now refuse instead of silently replacing data — the journalled-merge collision gate. No schema change.
Who is affected. Anyone who has journaling on (journal.enabled: true in
config, or a store constructed with journal_enabled=True) and restores a
snapshot without --clear into a target whose journal_entry table
already has rows, where an incoming record collides with one already there
on a primary key or a UNIQUE constraint. The concrete shape is a scripted
or periodic restore that merges overlapping or repeated snapshots into a
long-lived journalled store — that restore, run again the way it always was,
now fails instead of completing.
Executed, on a database with one journalled thought: restoring that same
database's own snapshot back into itself, with no --clear and no override:
Error: Restore refused: snapshot line 2 collides with an existing row (matching primary key or UNIQUE constraint), and the target's journal_entry table is not empty. Replacing that row would leave the audit trail describing data this merge discarded, while 'engrava verify' kept reporting the chain as valid. Re-run with --orphan-journal-entries to allow the merge and accept that gap, or with --clear to discard the journal along with the data.exit code 1, and nothing from the snapshot is written — the whole restore
rolls back, not only the colliding line. The identical restore with
--orphan-journal-entries added prints Restored 1 records from backup.jsonl
and exits 0, matching 0.6.x behaviour exactly.
Who is not affected. Anyone who has never enabled journaling — the
default, and the CLI never turns it on for you — never has a non-empty
journal_entry table, so the gate's condition is never met and every restore
behaves exactly as before. A restore that always passes --clear is also
unaffected: --clear empties journal_entry before the gate is evaluated,
so the check sees an empty table and never triggers, the same as it always
did. And a merge restore whose incoming records never collide with anything
already in the target — the common case for a snapshot restored into a
different, disjoint database — sees no refusal either, journalled or not.
What changed. Before this release, a merge restore inserted every record
with INSERT OR REPLACE unconditionally, so a collision silently replaced
the existing row (or, for a thought, cascaded to its edges, embeddings, and
actions) regardless of whether the target's journal already described it.
Now, while journal_entry is non-empty and neither --clear nor
--orphan-journal-entries is given, every incoming record is written with a
plain INSERT instead, so SQLite itself refuses the collision. The gate is
conservative, not precise: it refuses any such collision once a journal
exists, including one on a row the journal never actually described — it
does not try to work out in advance which collisions are safe.
What to do. If your restore fails with the message above and you have
weighed the resulting audit gap — the journal will describe data the merge
just replaced, though verify keeps reporting it as valid — add
--orphan-journal-entries to the same invocation to restore the previous
behaviour unchanged. If instead the target should be replaced wholesale, use
--clear, which empties the journal along with the data and never hits this
gate. Either way, this only ever fires on a target you already chose to
journal; see Backup & Recovery
and Audit Trail for the full
mechanism the override accepts.
Logger name change: the vector-search backend's logger moved with the module. No schema change, and no behaviour change to search itself — only where its log records appear.
engrava.extensions.vector_sqlite_vecwas re-homed toengrava.infrastructure.sqlite.vector_sqlite_vec(it was a SQLite adapter, not an extension); the old import path keeps working via an alias.The module's logger,
logging.getLogger(__name__), moved with it: it now reports asengrava.infrastructure.sqlite.vector_sqlite_vecinstead ofengrava.extensions.vector_sqlite_vec. The alias does not cover this — a logger's name comes from the real module's__name__, not the path used to import it.What to do: update any log-based alerting rule or dashboard filter keyed on the old logger name to the new one; otherwise those records stop appearing with no error or warning.
A real row-version guard on the four update paths, and a new revision
column behind it. Schema change: user_version 20 → 21, additive —
thought, edge and action each gain revision INTEGER NOT NULL DEFAULT 0
(not embedding, which carries no revision of its own). The migration runs
automatically on the next ensure_schema() / factory open; no backfill step
is needed, since the default already gives every pre-existing row revision = 0, identical to a freshly inserted one.
What changed. update_thought and restore_thought already carried a
version guard, but it compared updated_cycle — a cognitive-recency signal
nothing in the store ever advanced on its own, so an ordinary concurrent edit
passed it silently. That guard now compares and increments revision
instead, and the engine bumps it on every guarded write automatically, with
no caller action required to arm it. update_edge and update_action gain
the same guard for the first time — before this release, neither could ever
raise StaleDataError under any circumstance.
Who is affected. Anyone relying on the previous silence: a competing edit
that used to land unnoticed — whether from a second task nested inside a
caller-owned hook on the same store, or, for the first time, from a
second connection or process writing the same database file — now makes
the guarded write raise StaleDataError instead. Code that never saw a
conflict because conflicts were invisible needs a retry idiom: re-read the
row, recompute the intended change, and reissue the call — do not replay the
original arguments against the stale read. See
Concurrency for
the full contract.
What did not change. No public method gained, lost, or renamed a
parameter — revision is not a field on any domain model and is not
returned by model_dump() or any read path; it is an internal column the
guarded UPDATE statements alone read and write. There is still no
caller-supplied expectation to pass (no expected_revision argument on any
method) — a caller cannot yet assert "reject this write unless the row is
still exactly the one I read"; only the engine's own automatic bump is
enforced. create_thought / create_edge / create_action are unaffected:
a newly inserted row simply starts at revision = 0.
Stored timestamps are rewritten into one canonical UTC form. The same
user_version 20 → 21 migration also rewrites stored timestamp text; it adds no
column of its own.
What changed. Engrava now stores every timestamp it accepts in one form,
2026-07-01T00:00:00+00:00: a value with an offset is converted to UTC, and a
value without one is read as UTC. On 0.6.x a value without an offset was stored
exactly as written and compared as text, so 2026-07-01 00:00:00 sorted before
every 2026-07-01T… value of the same day, and a basic-format
(20260701T000000) or week-date (2026-W27-3) value sorted after every
extended value of the same year. An expires_at in those forms expired early, or
not until the next calendar year, and valid-time comparisons could match the
wrong rows.
What the upgrade does. The migration reads the stored values in
thought.created_at, updated_at, last_accessed_at, expires_at,
valid_from, valid_until and archived_at, and in edge.valid_from and
valid_until, that are not already in the canonical shape, and rewrites each
one it can read as an instant into that form. No instant changes, only the
text. A value that already has the canonical shape is not re-read, even if it
names an impossible date. Of the values it reads, one that cannot be read as an
ISO-8601 instant is left as it is and counted in one warning from the
engrava.infrastructure.sqlite.engrava_core logger, which names the affected
columns and their counts but not the values. The rewrite does not bump
revision, does not re-stamp updated_at, and writes no journal entry, so the
journal's hash chain still verifies.
The same form applies to timestamps you pass in. cleanup_expired(now=...)
and the journal's get_entries(since=...) compare their argument by instant,
reading a value without an offset as UTC, and raise ValueError for a value
they cannot read as a UTC instant; on 0.6.x the argument was compared as text.
MindQL valid_at / valid_within / valid_between literals are read the same
way (see Bi-temporal model). engrava restore writes a
snapshot's thought and edge timestamps in the canonical form, so a snapshot taken
on 0.6.x restores into the same form the upgrade produces; a value it cannot read
is restored as it was.
Who is affected. Anyone who stored timestamps without an offset or in a
non-extended ISO-8601 form: after the upgrade, expiry and valid-time comparisons
follow the real instant, with no action needed. Anyone who reads the database
file directly, or compares stored timestamp text outside engrava, now sees the
canonical form. Anyone passing a now= or since= value that cannot be read as
a UTC instant now gets ValueError.
What to do. Nothing, for the upgrade itself. If the warning above appears, find the values in the named columns that are not ISO-8601 timestamps and repair them.
store_embedding()'s base row write and its vector-index update are now one
failure-atomic unit, and the embedding-identity check now runs on every call
instead of only the first. No schema change.
What changed. store_embedding() now wraps the embedding row write and
the vector backend's index update (when a vector backend is configured) in
the same savepoint the four update paths above already use, so a failure in
the index update (e.g. a vector rejected by the vector backend) no longer
leaves the row write pending for a later, unrelated commit on the same
connection to pick up. Separately, _ensure_embedding_model_lock no longer
skips its comparison after the first call on a store instance: the stored
model name, dimension, and document-prefix fingerprint are now compared
against every embedding write and every verify_embedding_model() call, not
cached as "already verified" after the first one.
Who is affected. Anyone who kept one long-lived store instance around
while pointing it at a rotating or growing set of embedding models or
dimensions, relying on the mismatch check never firing again after the first
successful write. The next write with a different model name, dimension, or
document prefix now raises EmbeddingModelMismatchError instead of being
accepted silently, except the internal dreaming-centroid write, which is
exempt from this check. Anyone using a single model/dimension per store
instance sees no behaviour change from the identity check.
What to do. If a store instance is intentionally reused across different
embedding configurations, construct a new store instance (or a new database)
per configuration instead, or catch EmbeddingModelMismatchError and handle
the switch explicitly (re-embed, or route to a different store).
0.5 -> 0.6
Version 0.6 is a schema-changing minor upgrade. Do not roll it across old and
new workers sharing one database file. Back up the database, stop the 0.5
workers, let one 0.6 process run ensure_schema() (or engrava migrate) to
completion, and then start the 0.6 workers. The migration is automatic and
forward-only.
The core schema advances from user_version = 18 to user_version = 20 in two
additive steps:
| Step | Change | Existing-row behavior |
|---|---|---|
| 18 → 19 | Adds edge.metadata_json | Existing edges read back metadata == {}. |
| 19 → 20 | Adds nullable thought.archived_at | An older hygiene-archived row has no wall-clock timestamp and fails closed: it is not auto-GC-eligible while the wall-clock restore window is active. |
Neither step drops a table or rewrites user content. The columns are added
automatically with a neutral default or NULL. A physical pre-upgrade backup is
still required because downgrades and reverse migrations are unsupported.
Review these behavior changes before deployment:
Archived thoughts leave default retrieval.
search_similar,search_fts,search_hybrid, andrecallnow excludeLifecycleStatus.ARCHIVEDrows by default. Passinclude_archived=Truefor an archive-search call, or userestore_thought(...)to return a thought toACTIVE.list_thoughtsandcount_thoughtsretain their lifecycle-neutral behavior unless you filter them explicitly.Query-vector dimensions fail loudly.
search_similarand the vector arm reject a vector whose length does not match the store dimension withVectorDimensionMismatchError(anEngravaErrorsubclass). Code that caught the previous incidentalValueError, or relied on a wrong-dimension all-zero vector returning[], must catch the typed error instead. Empty, all-zero, or non-finite vectors remain a graceful empty result and incrementvector_arm_degradation_count.Custom embedding providers must expose a public
dimension.EmbeddingProviderProtocolhas always required it, but nothing checked it at construction and the 0.5 core read it at exactly one site, off the query path — so a provider that kept the value privately (self._dimension, no public property) worked on 0.5 for as long as nothing calledverify_embedding_model(), which is that site. 0.6 reads it on every vector search, before any comparison — unless asqlite-vecbackend is configured, whose ownvec0table declares the dimension and is consulted first. Such a provider now raisesEmbeddingProviderContractError(anEngravaErrorsubclass) naming the provider class and the missing member:embedding provider 'MyProvider' does not expose the required EmbeddingProviderProtocol member 'dimension'. Add a public 'dimension' property to 'MyProvider' — a private attribute such as '_dimension' does not satisfy the protocol.The fix is to add the property:
@property def dimension(self) -> int: return self._dimensionThe check is lazy: constructing a store with such a provider still succeeds, and a store that never searches by vector — and never calls
verify_embedding_model()— is unaffected. Callverify_embedding_model()after construction to fail at startup instead; it raises the same typed error. This is not a contract change — the protocol required the member before 0.6; only the point at which it is read, and the error you get, have changed.An edge stored with
decay_multiplier = 0.0read back as1.0on 0.5.x. The column held the value you wrote, but the decode tested it for truthiness and substituted the1.0default, so a deliberate0.0never survived the read. Becauseupdate_edgerebuilds the whole record from that read and writes every column back, any later change to such an edge — a newweight, or aninvalidate_edgecall closing its valid-time interval — persisted1.0over the stored0.0, unless that same call setdecay_multiplieritself. 0.6 decodes the column on presence rather than truthiness, so0.0now round-trips as written. Upgrading does not restore a value that was already overwritten: where a 0.5.x update replaced a stored0.0, the database now holds1.0, and 0.6 reads that1.0back faithfully. If you setdecay_multiplier = 0.0on any edge deliberately, check those edges after upgrading and set the value again where it now reads1.0.Malformed FTS syntax gets one safe retry. A failed expert
MATCHexpression is retried through bare-query normalization before the FTS arm is dropped; every failed first attempt incrementsfts_match_failure_count.Recency has two explicit modes. Existing
current_cyclebehavior remains, with an optional runtimecycle_provider. Callers without a cognitive cadence can select transaction-time recency withrecency_now; supplying both explicit references raisesRecencyModeConflictErrorrather than combining clocks.Configuration validation is uniform. Invalid values in supported config sections are rejected consistently whether the store is built from YAML or through the corresponding typed construction path. Fix invalid legacy values rather than relying on a path that previously skipped validation.
Enabled hygiene gains conservative wall-clock guards. Memory Hygiene is still off by default. For a store that already enabled it in 0.5, omitted new fields resolve to a seven-day minimum inactivity age and a 30-day wall-clock restore window; archival also requires an active usage-history signal. Pass a fixed
nowwhen replaying a selection, and review the new defaults before the first 0.6 hygiene run. Existing hygiene archives withoutarchived_atfail closed and are not auto-GC'd while the wall-clock window is active.
The new edge-metadata, derived-record, cycle-provider, and transaction-time recency surfaces are additive. Existing stores do not enable automatic derivation or a cycle provider merely by being migrated.
0.4 -> 0.5
The library upgrade is drop-in: pip install --upgrade engrava and your normal
startup. The schema migration runs automatically on first open as usual.
Schema change (additive, zero data loss). 0.5 steps the core schema from
user_version = 14 to user_version = 18 in four migrations:
| Step | Change | Existing-row behavior |
|---|---|---|
| 14 → 15 | Adds the composite edge(edge_type, to_thought_id) lookup index | Query-plan improvement only; no row changes. |
| 15 → 16 | Adds nullable thought.action_outcome_score and idx_action_source_thought | Existing thoughts have no action-outcome aggregate until linked terminal actions produce one. |
| 16 → 17 | Adds nullable thought.provenance plus session/actor JSON expression indexes | Existing thoughts have no captured provenance. |
| 17 → 18 | Adds thought.pinned and nullable thought.archived_at_cycle | Existing thoughts read as pinned=False; no row is treated as hygiene-archived. |
All four steps run automatically, in order, on first ensure_schema() —
each is committed on its own (there is no single transaction spanning all
four; user_version advances one step at a time as each step succeeds). They
add columns or indexes without dropping or rewriting user content.
Breaking change for MCP-server users. The Model Context Protocol server moved
out of engrava into its own package, engrava-mcp. Removed from engrava
in 0.5.0:
the
engrava[mcp]optional-dependency extra, andthe in-engrava
engrava-mcpconsole command.
Migrate as follows:
| Before (0.4) | After (0.5) |
|---|---|
pip install "engrava[mcp]" | pip install engrava-mcp (or uvx engrava-mcp) |
engrava-mcp (installed by engrava) | engrava-mcp (installed by the engrava-mcp package) |
client mcp.json: command: engrava-mcp | client mcp.json: command: uvx, args: ["engrava-mcp"] |
Watch out:
pip install "engrava[mcp]"against engrava 0.5 does not fail — pip ignores the now-unknown extra and installs bareengrava, so you may think the server was installed when it was not. Installengrava-mcpinstead.Update any pinned requirement strings (
engrava[mcp]>=...) to depend onengrava-mcp, not just reinstall.Store config is unchanged: the server still reads
ENGRAVA_MCP_CONFIG(anengrava.yaml) orENGRAVA_DB_PATH, andENGRAVA_MCP_READ_ONLYstill gates the write tools.engrava-mcpdepends onengrava>=0.5, so if an app on engrava 0.4.x and anengrava-mcpshare one database file, upgrade the app to 0.5 too.Don't run the old in-engrava
engrava-mcpand the newengrava-mcppackage against the same store at once during the cutover.
See the engrava-mcp package for the
full server documentation (install, client config, tools/resources/prompts,
read-only mode).
0.3 -> 0.4
Version 0.4 introduces a second time axis — valid time (valid_from /
valid_until), the period during which a fact is true in the world — alongside
the existing transaction time (created_at). See
The Bi-temporal Model for the full feature, the four query
predicates, and invalidate. From an upgrade standpoint, the change is
additive and automatic:
The migration runs on first open, with zero data loss. The first time a
0.4 process calls ensure_schema() (most apps already do this at startup), the
core schema steps forward from user_version = 12 (the 0.3 schema) to
user_version = 14 in two additive steps (12 → 13 adds the valid-time
columns and their indexes; 13 → 14 adds the hot-path indexes), each committed
on its own as it succeeds — not as one shared transaction across both.
pip install --upgrade engrava plus your normal startup is all that
is required:
pip install --upgrade engrava
# your app's existing ensure_schema() call performs the migration on first openWhat the migration does:
Adds two nullable columns —
valid_fromandvalid_until— to both thethoughtandedgetables, plus supporting indexes. Nothing is dropped or rewritten beyond adding columns; no row is lost or modified in content, and the row counts are unchanged.Backfills existing thoughts conservatively. A thought that has a recorded
created_atgetsvalid_frombackfilled from it (its valid-time lower bound starts where its transaction time started).valid_untilis always left open (NULL).Leaves legacy rows and all edges open-from. A thought with no
created_at(a legacy row) keepsvalid_from = NULL. Every existing edge keeps both boundsNULL— the edge table has no calendar timestamp to source a date from, so the migration honestly leaves them open rather than fabricating one.Adds four hot-path indexes. A second additive step creates indexes that back the equality filters and the sort column hit on every common read (edges by their target thought, a thought's embedding by owner, listing thoughts in recency order, and filtering thoughts by type). This is a pure index addition — no row is read, modified, or removed, and the row counts are unchanged. The connection is also opened with
synchronous=NORMALandbusy_timeout=5000(a PRAGMA-only change with no on-disk effect). Like the valid-time step, it runs automatically on first open with zero data loss.
Structured (MindQL) queries are unchanged. A query that uses no temporal
predicate behaves exactly as it did on 0.3. And because a NULL bound is treated
as an open interval end (−∞ / +∞), the open-from rows above still match
valid_now and valid_at queries — an un-dated fact is treated as "valid since
the beginning of time", not as "excluded". So adopting valid time is incremental:
you can start annotating new facts whenever you like, and the old ones keep
surfacing in temporal queries until you choose to bound them.
Search behavior changes (no migration, but worth knowing). Two 0.4 fixes to keyword/full-text search are not schema changes but do change results:
Bare full-text queries now
OR-match instead ofAND-matching, so a natural-language query that returned nothing on 0.3 (because no document contained every word) may now return results. This is the intended fix; if you relied on strict all-words matching, use uppercaseANDor a quoted phrase explicitly. See Keyword query syntax.Stored embeddings are not re-computed by the upgrade — the full-content embedding fix and the
max_seq_lengthfix take effect only when a thought is re-written (re-created, or itsessence/contentupdated), at which point it is re-embedded with the corrected input. Existing vectors are untouched until then.
Honest note about edges. Because the upgrade cannot invent a
valid_fromfor an edge that never had a date, every edge migrated from 0.3 carriesvalid_from = NULL. That is the correct "open lower bound", so those edges still matchvalid_now/valid_at. They will not matchvalid_between(which requires real bounds on both ends) until you set their bounds explicitly. This is expected, not a defect.
MCP server. 0.4 shipped an optional Model Context Protocol server behind an
in-tree engrava[mcp] extra. As of 0.5 the server moved to its own package,
engrava-mcp (uvx engrava-mcp); the
engrava[mcp] extra and the in-engrava engrava-mcp command are removed. See the
0.4 → 0.5 notes below for migration.
This is a schema-changing minor upgrade, so follow the rolling-upgrades procedure (back up, quiesce writers, migrate once, start new workers) if you run multiple processes against one database file.
0.3.0 -> 0.3.1
Patch release: no schema change (
user_versionstays at its 0.3.0 value), so it is safe to roll across multiple workers without a quiesce.
0.2.0 -> 0.3.0
Extension schema migration tracking is now part of the upgrade path.
Upgrade-path CI validates the previous release against a wheel built from
HEAD, not a fixed version pair or an editable checkout. That wheel still carriesHEAD's ownpyproject.tomlversion — the release pipeline bumps the version later, in its own separate build — so it is not the exact wheel that gets published, only the same source tree.ENGRAVA_UPGRADE_FROM_SPECis bumped on every release so the job always exercises last-released ->HEAD.Release notes and
CHANGELOG.mdnow carry a dedicatedDatabase Changessection for schema-affecting releases.
Dreaming Defaults
Future releases that change dreaming defaults should document them here. For
example, a benchmark-facing default such as dreaming_cycles=1 belongs in this
guide once it becomes part of a shipped release.
Release Communication Rule
Any release that changes schema behavior must include a Database Changes
section in CHANGELOG.md and in GitHub release notes.
402 files · 357 not served here
- .commitlintrc.js (not served here)
- .cursorrules (not served here)
- .gitattributes (not served here)
- .githooks/commit-msg (not served here)
- .github/ISSUE_TEMPLATE/bug.md (not served here)
- .github/ISSUE_TEMPLATE/config.yml (not served here)
- .github/ISSUE_TEMPLATE/feature.md (not served here)
- .github/ISSUE_TEMPLATE/question.md (not served here)
- .github/copilot-instructions.md
- .github/dependabot.yml (not served here)
- .github/pull_request_template.md (not served here)
- .github/release-notes-template.md (not served here)
- .github/workflows/branch-name-guard.yml (not served here)
- .github/workflows/ci.yml (not served here)
- .github/workflows/commitlint.yml (not served here)
- .github/workflows/main-carries-the-released-tag.yml (not served here)
- .github/workflows/release-merge-discipline.yml (not served here)
- .github/workflows/release.yml (not served here)
- .github/workflows/secret-scan.yml (not served here)
- .github/workflows/smoke-gate.yml (not served here)
- .gitignore (not served here)
- .releaserc.json (not served here)
- AGENTS.md
- ATTRIBUTION.md
- AUTOGINEER-RUNS.md
- BRANCHING.md
- CHANGELOG.md
- CLAUDE.md
- CODE_OF_CONDUCT.md
- CONTRIBUTING.md
- LICENSE
- MANIFEST.in (not served here)
- Makefile (not served here)
- README.md
- SECURITY.md
- commit-scopes.json (not served here)
- docs/api-reference.md
- docs/architecture.md
- docs/audit-trail.md
- docs/backup-and-recovery.md
- docs/benchmarks.md
- docs/bitemporal.md
- docs/cli.md
- docs/concepts.md
- docs/concurrency.md
- docs/configuration.md
- docs/data-lifecycle.md
- docs/deployment.md
- docs/dreaming.md
- docs/error-handling.md
- docs/evidence-and-conflicts.md
- docs/extension-hooks.md
- docs/extensions.md
- docs/faq.md
- docs/glossary.md
- docs/guides/agent-memory.md (not served here)
- docs/guides/embeddings.md (not served here)
- docs/guides/migrating-from-other-memory.md (not served here)
- docs/index.md
- docs/known-limitations.md
- docs/memory-hygiene.md
- docs/mindql.md
- docs/observability.md
- docs/performance.md
- docs/positioning.md
- docs/quickstart.md
- docs/recipes/index.md (not served here)
- docs/scopes.md
- docs/search.md
- docs/security.md
- docs/troubleshooting.md
- docs/tutorial.md
- docs/upgrade.md
- examples/README.md (not served here)
- examples/agent_loop.py (not served here)
- examples/config.yaml (not served here)
- examples/mcp-client-config.db-path.json (not served here)
- examples/mcp-client-config.json (not served here)
- examples/mcp-client-config.readonly.json (not served here)
- examples/mcp-client-config.vscode.json (not served here)
- examples/notes_memory.py (not served here)
- examples/profile-lexical.yaml (not served here)
- examples/profile-local.yaml (not served here)
- examples/profile-network-ollama.yaml (not served here)
- examples/quickstart.py (not served here)
- examples/simple_agent.py (not served here)
- pyproject.toml (not served here)
- release-target.json (not served here)
- scripts/backfill_content_hashes.py (not served here)
- scripts/build_upgrade_fixture.py (not served here)
- scripts/bump_pyproject_version.py (not served here)
- scripts/check_computed_version_matches_target.py (not served here)
- scripts/check_cpu_only_torch.py (not served here)
- scripts/check_hooks_active.sh (not served here)
- scripts/check_main_carries_the_released_tag.py (not served here)
- scripts/check_release_branch_merge_discipline.py (not served here)
- scripts/check_release_target_was_published.py (not served here)
- scripts/check_schema_release_gate.py (not served here)
- scripts/check_smoke_gate.py (not served here)
- scripts/install_gitleaks.sh (not served here)
- scripts/install_hooks.sh (not served here)
- scripts/rebalance_p1.py (not served here)
- scripts/reenrich_reflections_to_v2.py (not served here)
- scripts/regenerate_search_goldens.py (not served here)
- scripts/run_schema_release_gate.sh (not served here)
- scripts/run_upgrade_test.sh (not served here)
- scripts/verify_artifact_version.py (not served here)
- scripts/verify_dist_cardinality.py (not served here)
- scripts/verify_release_artifacts.sh (not served here)
- scripts/verify_release_content_scan.sh (not served here)
- scripts/verify_wheel_data.py (not served here)
- scripts/verify_wheel_execution.py (not served here)
- src/engrava/__init__.py (not served here)
- src/engrava/_composition.py (not served here)
- src/engrava/benchmarks/__init__.py (not served here)
- src/engrava/benchmarks/longmemeval/README.md (not served here)
- src/engrava/benchmarks/longmemeval/__init__.py (not served here)
- src/engrava/benchmarks/longmemeval/__main__.py (not served here)
- src/engrava/benchmarks/longmemeval/cli.py (not served here)
- src/engrava/benchmarks/longmemeval/dataset_loader.py (not served here)
- src/engrava/benchmarks/longmemeval/evaluate.py (not served here)
- src/engrava/benchmarks/longmemeval/runner.py (not served here)
- src/engrava/benchmarks/synthetic/README.md (not served here)
- src/engrava/benchmarks/synthetic/__init__.py (not served here)
- src/engrava/benchmarks/synthetic/__main__.py (not served here)
- src/engrava/benchmarks/synthetic/datasets/__init__.py (not served here)
- src/engrava/benchmarks/synthetic/datasets/synthetic-v1.json (not served here)
- src/engrava/benchmarks/synthetic/evaluate.py (not served here)
- src/engrava/benchmarks/synthetic/generate.py (not served here)
- src/engrava/benchmarks/synthetic/runner.py (not served here)
- src/engrava/benchmarks/synthetic/scenarios.py (not served here)
- src/engrava/cli/__init__.py (not served here)
- src/engrava/cli/config.py (not served here)
- src/engrava/cli/exception_reporting.py (not served here)
- src/engrava/cli/main.py (not served here)
- src/engrava/cli/memory_commands.py (not served here)
- src/engrava/cli/snapshot_records.py (not served here)
- src/engrava/cli/store_resolution.py (not served here)
- src/engrava/config.py (not served here)
- src/engrava/config_validation.py (not served here)
- src/engrava/cycle_providers.py (not served here)
- src/engrava/domain/__init__.py (not served here)
- src/engrava/domain/dreaming.py (not served here)
- src/engrava/domain/enums.py (not served here)
- src/engrava/domain/exceptions.py (not served here)
- src/engrava/domain/manifest.py (not served here)
- src/engrava/domain/models/__init__.py (not served here)
- src/engrava/domain/models/_temporal.py (not served here)
- src/engrava/domain/models/action.py (not served here)
- src/engrava/domain/models/edge.py (not served here)
- src/engrava/domain/models/embedding.py (not served here)
- src/engrava/domain/models/filters.py (not served here)
- src/engrava/domain/models/journal.py (not served here)
- src/engrava/domain/models/metrics.py (not served here)
- src/engrava/domain/models/mutation_type.py (not served here)
- src/engrava/domain/models/provenance.py (not served here)
- src/engrava/domain/models/search.py (not served here)
- src/engrava/domain/models/thought.py (not served here)
- src/engrava/domain/models/ttl.py (not served here)
- src/engrava/domain/protocols/__init__.py (not served here)
- src/engrava/domain/protocols/cycle_provider.py (not served here)
- src/engrava/domain/protocols/derived_records.py (not served here)
- src/engrava/domain/protocols/dreaming.py (not served here)
- src/engrava/domain/protocols/embedding_provider.py (not served here)
- src/engrava/domain/protocols/engrava_core.py (not served here)
- src/engrava/domain/protocols/engrava_read.py (not served here)
- src/engrava/domain/protocols/hooks.py (not served here)
- src/engrava/embeddings/__init__.py (not served here)
- src/engrava/embeddings/callback.py (not served here)
- src/engrava/embeddings/huggingface.py (not served here)
- src/engrava/embeddings/ollama.py (not served here)
- src/engrava/embeddings/openai_compatible.py (not served here)
- src/engrava/embeddings/sentence_transformer.py (not served here)
- src/engrava/extensions/__init__.py (not served here)
- src/engrava/extensions/discovery.py (not served here)
- src/engrava/extensions/dreaming.py (not served here)
- src/engrava/extensions/dreaming_cluster_quality.py (not served here)
- src/engrava/extensions/dreaming_keyphrases.py (not served here)
- src/engrava/extensions/dreaming_reflection_content.py (not served here)
- src/engrava/extensions/dreaming_reflection_extent.py (not served here)
- src/engrava/extensions/dreaming_signals.py (not served here)
- src/engrava/extensions/structural_split.py (not served here)
- src/engrava/extensions/vector_sqlite_vec.py (not served here)
- src/engrava/infrastructure/__init__.py (not served here)
- src/engrava/infrastructure/read_only_store.py (not served here)
- src/engrava/infrastructure/service_manager.py (not served here)
- src/engrava/infrastructure/sqlite/__init__.py (not served here)
- src/engrava/infrastructure/sqlite/aiosqlite_connect.py (not served here)
- src/engrava/infrastructure/sqlite/centroid.py (not served here)
- src/engrava/infrastructure/sqlite/connection_revocation.py (not served here)
- src/engrava/infrastructure/sqlite/engrava_core.py (not served here)
- src/engrava/infrastructure/sqlite/extension_migrations.py (not served here)
- src/engrava/infrastructure/sqlite/hygiene.py (not served here)
- src/engrava/infrastructure/sqlite/journal_writer.py (not served here)
- src/engrava/infrastructure/sqlite/schema_core.sql (not served here)
- src/engrava/infrastructure/sqlite/vector_sqlite_vec.py (not served here)
- src/engrava/metadata.py (not served here)
- src/engrava/mindql/__init__.py (not served here)
- src/engrava/mindql/executor.py (not served here)
- src/engrava/mindql/parser.py (not served here)
- src/engrava/py.typed (not served here)
- tests/__init__.py (not served here)
- tests/_shell_command.py (not served here)
- tests/_workflow_yaml.py (not served here)
- tests/benchmarks/__init__.py (not served here)
- tests/benchmarks/fixtures/longmemeval_tiny.json (not served here)
- tests/benchmarks/test_longmemeval_cli.py (not served here)
- tests/benchmarks/test_longmemeval_evaluate.py (not served here)
- tests/benchmarks/test_longmemeval_hygiene.py (not served here)
- tests/benchmarks/test_longmemeval_llm_mode.py (not served here)
- tests/benchmarks/test_longmemeval_loader.py (not served here)
- tests/benchmarks/test_longmemeval_runner.py (not served here)
- tests/benchmarks/test_synthetic_e2e.py (not served here)
- tests/benchmarks/test_synthetic_evaluate.py (not served here)
- tests/benchmarks/test_synthetic_frozen.py (not served here)
- tests/benchmarks/test_synthetic_generator.py (not served here)
- tests/benchmarks/test_synthetic_scenarios.py (not served here)
- tests/conftest.py (not served here)
- tests/data/bare_command_goldens.json (not served here)
- tests/docs/__init__.py (not served here)
- tests/docs/_documented_defaults_scan.py (not served here)
- tests/docs/_import_resolution.py (not served here)
- tests/docs/_md_blocks.py (not served here)
- tests/docs/test_docs_config_defaults.py (not served here)
- tests/docs/test_docs_config_examples.py (not served here)
- tests/docs/test_docs_examples_behavior.py (not served here)
- tests/docs/test_docs_examples_compile.py (not served here)
- tests/docs/test_docs_examples_coverage.py (not served here)
- tests/docs/test_docs_examples_execute.py (not served here)
- tests/docs/test_docs_examples_full_census.py (not served here)
- tests/docs/test_docs_examples_imports.py (not served here)
- tests/docs/test_docs_exceptions_table.py (not served here)
- tests/docs/test_docs_no_commit_citations.py (not served here)
- tests/docs/test_docs_shell_examples.py (not served here)
- tests/docs/test_error_handling_names_every_exception.py (not served here)
- tests/docs/test_md_blocks_fences.py (not served here)
- tests/docs/test_schema_version_error_docs_coverage.py (not served here)
- tests/domain/__init__.py (not served here)
- tests/domain/models/__init__.py (not served here)
- tests/domain/models/test_filters.py (not served here)
- tests/domain/models/test_temporal_fields.py (not served here)
- tests/domain/test_derived_records_types.py (not served here)
- tests/examples/__init__.py (not served here)
- tests/examples/_examples_census.py (not served here)
- tests/examples/test_examples_cleanup_close.py (not served here)
- tests/examples/test_examples_readme_census.py (not served here)
- tests/examples/test_mcp_client_configs.py (not served here)
- tests/examples/test_quickstart_runs.py (not served here)
- tests/extensions/__init__.py (not served here)
- tests/extensions/test_structural_split.py (not served here)
- tests/search_contract/__init__.py (not served here)
- tests/search_contract/conftest.py (not served here)
- tests/search_contract/golden_fixtures.py (not served here)
- tests/search_contract/goldens/from_config_search.yaml (not served here)
- tests/search_contract/goldens/fts_expert_normalization.json (not served here)
- tests/search_contract/goldens/hybrid_ranked_results.json (not served here)
- tests/search_contract/goldens/hybrid_ranked_results_from_config.json (not served here)
- tests/search_contract/test_archived_retrieval_exclusion.py (not served here)
- tests/search_contract/test_cross_arm_liveness.py (not served here)
- tests/search_contract/test_fts_expert_syntax_fallback.py (not served here)
- tests/search_contract/test_fts_fallback_log_content.py (not served here)
- tests/search_contract/test_fts_query_normalization_robustness.py (not served here)
- tests/search_contract/test_hybrid_weight_conservation.py (not served here)
- tests/search_contract/test_search_contract.py (not served here)
- tests/search_contract/test_search_goldens.py (not served here)
- tests/search_contract/test_vector_arm_degradation.py (not served here)
- tests/test_action_outcome_feedback.py (not served here)
- tests/test_aiosqlite_connect.py (not served here)
- tests/test_auto_embed_failure_log_content.py (not served here)
- tests/test_backfill_content_hashes.py (not served here)
- tests/test_begin_immediate_contention_is_typed.py (not served here)
- tests/test_cancellation_during_write_unit_entry.py (not served here)
- tests/test_check_cpu_only_torch.py (not served here)
- tests/test_ci_workflow_permissions.py (not served here)
- tests/test_cli.py (not served here)
- tests/test_cli_memory_verbs.py (not served here)
- tests/test_cli_memory_verbs_config.py (not served here)
- tests/test_cli_memory_verbs_embedding.py (not served here)
- tests/test_cli_memory_verbs_error_boundary.py (not served here)
- tests/test_cli_store_resolution_goldens.py (not served here)
- tests/test_cognitive_journal.py (not served here)
- tests/test_commit_scope_vocabulary.py (not served here)
- tests/test_concurrency_contract.py (not served here)
- tests/test_config.py (not served here)
- tests/test_config_validation_parity.py (not served here)
- tests/test_create_delete_journal_append_atomicity.py (not served here)
- tests/test_create_thought_performance.py (not served here)
- tests/test_cycle_provider.py (not served here)
- tests/test_de_fragmentation_collapse.py (not served here)
- tests/test_dedup_migration.py (not served here)
- tests/test_dedup_write_contention.py (not served here)
- tests/test_deletion_blast_radius.py (not served here)
- tests/test_deprecated_aliases.py (not served here)
- tests/test_derived_child_failure_transaction_isolation.py (not served here)
- tests/test_derived_records_seam.py (not served here)
- tests/test_derived_reuse_transaction_cleanup.py (not served here)
- tests/test_docs_anchor_links.py (not served here)
- tests/test_docs_no_deleted_observability_symbols.py (not served here)
- tests/test_dreaming.py (not served here)
- tests/test_dreaming_activation.py (not served here)
- tests/test_dreaming_boilerplate_filter.py (not served here)
- tests/test_dreaming_cluster_quality.py (not served here)
- tests/test_dreaming_clusters.py (not served here)
- tests/test_dreaming_content_v1_v2_compat.py (not served here)
- tests/test_dreaming_edges.py (not served here)
- tests/test_dreaming_gating_integration.py (not served here)
- tests/test_dreaming_integration.py (not served here)
- tests/test_dreaming_keyphrases.py (not served here)
- tests/test_dreaming_keyphrases_blocklist.py (not served here)
- tests/test_dreaming_lifecycle.py (not served here)
- tests/test_dreaming_metadata_filter.py (not served here)
- tests/test_dreaming_priority_bias.py (not served here)
- tests/test_dreaming_reflection_content.py (not served here)
- tests/test_dreaming_reflection_content_quality_fixes.py (not served here)
- tests/test_dreaming_reflection_extent.py (not served here)
- tests/test_edge_metadata.py (not served here)
- tests/test_edge_metadata_migration.py (not served here)
- tests/test_embedding_providers.py (not served here)
- tests/test_end_to_end_integration.py (not served here)
- tests/test_engrava_completeness.py (not served here)
- tests/test_exception_exports.py (not served here)
- tests/test_extension_infrastructure_boundary.py (not served here)
- tests/test_extension_load_with_migrations.py (not served here)
- tests/test_extension_migrations.py (not served here)
- tests/test_failed_commit_recovery.py (not served here)
- tests/test_from_config_integration.py (not served here)
- tests/test_hooks_active_gate.py (not served here)
- tests/test_hooks_wiring.py (not served here)
- tests/test_hot_path_index_migration.py (not served here)
- tests/test_hybrid_search_enhanced.py (not served here)
- tests/test_hygiene_journal_append_atomicity.py (not served here)
- tests/test_ingest_deduplication.py (not served here)
- tests/test_ingest_ergonomics.py (not served here)
- tests/test_invalidate.py (not served here)
- tests/test_journal_append_atomicity.py (not served here)
- tests/test_journal_append_recurrence_guard.py (not served here)
- tests/test_journal_verification.py (not served here)
- tests/test_main_carries_the_released_tag.py (not served here)
- tests/test_memory_hygiene.py (not served here)
- tests/test_metadata_helpers.py (not served here)
- tests/test_metadata_migration.py (not served here)
- tests/test_metrics.py (not served here)
- tests/test_metrics_measured_census.py (not served here)
- tests/test_migration_upgrade_chains.py (not served here)
- tests/test_mind_store_branches.py (not served here)
- tests/test_mind_store_core.py (not served here)
- tests/test_mindql.py (not served here)
- tests/test_outer_rollback_recovery.py (not served here)
- tests/test_partial_field_updates.py (not served here)
- tests/test_prepare_thought_for_insert_seam.py (not served here)
- tests/test_provenance_context_capture.py (not served here)
- tests/test_pyproject_urls.py (not served here)
- tests/test_quickstart_download_size_figure.py (not served here)
- tests/test_read_only_store.py (not served here)
- tests/test_rebalance_p1.py (not served here)
- tests/test_reenrich_reflections_to_v2.py (not served here)
- tests/test_referential_integrity.py (not served here)
- tests/test_reflection_centroid_staleness.py (not served here)
- tests/test_reflection_orphan_lifecycle.py (not served here)
- tests/test_release_branch_merge_discipline.py (not served here)
- tests/test_release_merge_discipline_workflow.py (not served here)
- tests/test_release_target_gate.py (not served here)
- tests/test_release_target_was_published.py (not served here)
- tests/test_remember_recall.py (not served here)
- tests/test_retrieval_microperf.py (not served here)
- tests/test_revision_guard_matrix.py (not served here)
- tests/test_schema_release_gate.py (not served here)
- tests/test_schema_version_gate.py (not served here)
- tests/test_scoped_retrieval.py (not served here)
- tests/test_search_defaults_single_source.py (not served here)
- tests/test_search_graph_weight.py (not served here)
- tests/test_search_hybrid_graph.py (not served here)
- tests/test_search_hybrid_reflection_cap.py (not served here)
- tests/test_search_top_k_floor.py (not served here)
- tests/test_service_isolation.py (not served here)
- tests/test_service_manager_cancellation_isolation.py (not served here)
- tests/test_shell_command.py (not served here)
- tests/test_smoke_gate.py (not served here)
- tests/test_snapshot_dreaming_roundtrip.py (not served here)
- tests/test_snapshot_records.py (not served here)
- tests/test_sqlite_vec.py (not served here)
- tests/test_stale_embedding_completion.py (not served here)
- tests/test_store_embedding_atomicity.py (not served here)
- tests/test_temporal_query_performance.py (not served here)
- tests/test_thought_metadata.py (not served here)
- tests/test_tier_boundary_guard.py (not served here)
- tests/test_timestamp_canonical_form.py (not served here)
- tests/test_timestamp_normalisation_migration.py (not served here)
- tests/test_timestamp_parameters.py (not served here)
- tests/test_transaction_recency.py (not served here)
- tests/test_ttl_auto_expiry.py (not served here)
- tests/test_two_process_dedup.py (not served here)
- tests/test_two_process_read_after_the_wait.py (not served here)
- tests/test_two_process_write_busy_wait.py (not served here)
- tests/test_update_readback_atomicity.py (not served here)
- tests/test_valid_interval_invariant.py (not served here)
- tests/test_valid_time_migration.py (not served here)
- tests/test_vec0_numpy_ranking_parity.py (not served here)
- tests/test_verify_wheel_execution.py (not served here)
- tests/upgrade/__init__.py (not served here)
- tests/upgrade/fixtures.py (not served here)
- tests/upgrade/test_upgrade_matrix.py (not served here)
Advantages
- Zero external service dependencies in the core write path. evidence: README.md
- Supports bi-temporal valid-time predicates for querying facts as of any instant. evidence: docs/index.md
- Provides a tamper-evident journal for auditing thought and edge mutations. evidence: README.md
Limitations
- Requires an optional embedding provider for semantic vector search functionality. evidence: docs/index.md
- The local embedding model option involves a large initial download. evidence: README.md
- Garbage collection of archived thoughts is a destructive, non-reversible operation. evidence: README.md
Security
Risk level: Low risk
This brain runs
Git hooks (1)
.githooks/commit-msg(commit-msg):runs on git commit-msg
Agent instructions that run or fetch something (4)
AGENTS.md:Run `git commit` and it fires on the patterns it checks for.CLAUDE.md:pip install -e ".[dev]"CLAUDE.md:Run a clean test suite locally before pushing;CLAUDE.md:Branch from `dev`, follow Conventional Commits, run the local quality gate before pushing.
Install scripts (1)
Makefile(make install):echo "$(BLUE)>> Installing engrava + dev dependencies$(RESET)"; $(PIP) install --upgrade pip setuptools wheel; $(PIP) install -e ".[dev]"; bash scripts/install_hooks.sh; echo "$(GREEN)>> Done. Run 'make check' to verify quality gate.$(RESET)"
- Low risk: scripts-present (.githooks/commit-msg)
- Low risk: network-instructions (CLAUDE.md:40)
- Low risk: network-instructions (pyproject.toml:98)
- Low risk: scripts-present (scripts/install_hooks.sh)
Attribution and licence
- Created by
- Sovantica
- Declared authors
- Sovantica (declared in pyproject.toml)
- Publisher
- Sovantica (Official Sovantica project)
- Source
- sovantica/engrava · commit f6a8b73
- Licence
- MIT License (files served here)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by Sovantica.
Install from source
357 of its files are not served here, so there is no ZIP. sovantica/engrava at f6a8b73