Upgrade from 0.5 to 0.6
Upgrading the library is a normal package upgrade:
pip install --upgrade engrava
0.6 is a schema-changing minor upgrade, so do not roll it across old and new workers sharing one database file.
Before upgrading
Back up the database, then quiesce writers.
sqlite3 my-data.db "PRAGMA wal_checkpoint(TRUNCATE);"
cp my-data.db my-data.db.bak
If the database is in WAL mode and you do not checkpoint first, copy the database
together with its -wal and -shm files. Stop the 0.5 workers, let one 0.6
process run the migration to completion, then start the rest.
engrava --db my-data.db migrate
The migration also runs on the first ensure_schema() call — automatically, if
your application already makes that call. Opening the store through
SqliteEngravaCore.from_config() makes it for you. The direct constructor,
SqliteEngravaCore(conn, ...), does not: it only wires the object up, so an
application on that path runs 0.6 against the old schema unless it awaits
store.ensure_schema() itself, or you run the engrava migrate command above
before starting it.
The full OSS upgrade guide has the longer backup and recovery notes: Upgrade Guide.
What changes in 0.6
The core schema advances from user_version = 18 to 20 in two additive
steps, applied on first open. Neither drops a table or rewrites your content.
| Step | Change | Existing rows |
|---|---|---|
| 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 |
Behaviour changes to review
A 0.5 application can notice these without changing a line of its own code. The first four are the ones most likely to change what your code sees; the rest of this section covers the remainder.
Archived thoughts leave default retrieval. search_hybrid, recall,
search_fts, and search_similar now exclude rows whose lifecycle_status is
ARCHIVED. If you archive thoughts, expect fewer results than on 0.5. Pass
include_archived=True for an archive-search call, or restore_thought(...) to
return a thought to ACTIVE. list_thoughts and count_thoughts are unaffected
and stay lifecycle-neutral. See
Hybrid Search.
A wrong-dimension query vector raises. search_similar and the vector arm
now reject a vector whose length differs from the store’s embedding dimension
with VectorDimensionMismatchError, an EngravaError subclass. Code that caught
the previous incidental numpy ValueError, or relied on a wrong-length all-zero
vector returning [], must catch the typed error instead. The dimension check
runs first, so degeneracy is only forgiven at the declared length: an
all-zero or non-finite vector of the right length still returns a graceful empty
result and increments vector_arm_degradation_count, while an empty [] is a
length-0 mismatch and raises like any other wrong length. That applies wherever
the store declares a dimension — through a configured vector backend or an
embedding provider.
Inverted validity intervals raise. A record whose valid_from is after its
valid_until was previously accepted silently. 0.6 refuses it at write, and
refuses it again on read — but only where a stored row is turned back into a
record, and that boundary has two edges worth knowing:
- One inverted row breaks the whole listing.
list_thoughts()builds every matching row into a record in a single pass, so one bad row anywhere in the page raisesValidationErrorand the call returns nothing at all — not merely minus that row.count_thoughts()andget_thought()on a healthy id keep working, which is what makes the failure easy to misread as sporadic. - Ranked retrieval still returns it.
recall()andsearch_hybrid()yield(thought_id, score)pairs and never build the record, so an inverted row goes on competing for result slots. The error surfaces one step later, when you hydrate that id.
Equal bounds (a zero-length, instantaneous fact) and open None bounds remain
valid. Nothing reports the offending rows for you, so list them before you
upgrade:
SELECT thought_id FROM thought WHERE valid_from > valid_until;
A NULL on either side drops out of that comparison on its own.
Treat that as a candidate list, not a complete detector. It compares the two bounds as stored text. engrava normalises an offset-bearing timestamp to UTC but leaves a naive one exactly as written, so a store holding both forms can have the text order disagree with the real order — in either direction. A row that looks inverted may not be, and a genuinely inverted row may not appear. Re-check any row whose two bounds are written in different formats by parsing both as datetimes.
A row that is genuinely inverted has to be repaired before the reads that touch it
will work: swap the two bounds if they were transposed, clear one to NULL if the
interval was meant to be open, or delete the row. There is no migration step that
does this for you — 0.6 refuses such rows rather than rewriting them. See
Bi-temporal Model.
engrava gc refuses rather than stranding vectors. If the upgraded install
dropped the vector extra, a gc pass that is about to physically delete rows on
a database carrying a sqlite-vec index stops before deleting anything and
exits 1, because removing the rows without removing their vectors would strand
those vectors in the index. Reinstall as pip install 'engrava[vec]' and retry.
A --dry-run, and a run with nothing to delete, are never refused.
A stored decay_multiplier of 0.0 now reads back as 0.0. On 0.5.x the
edge column held the 0.0 you wrote, but the decode tested it for truthiness and
handed back the 1.0 default. Because update_edge rebuilds the whole record
from that read and writes every column back, any later change to such an edge — a
new weight, or an invalidate_edge closing its valid-time interval — persisted
1.0 over the stored 0.0, unless that same call set decay_multiplier itself.
0.6 decodes the column on presence, so 0.0 round-trips as written.
Upgrading does not restore an already-overwritten value. Where a 0.5.x update
replaced a stored 0.0, the database now holds 1.0, and 0.6 reads that back
faithfully. If you set 0.0 on any edge deliberately, check those edges after
upgrading and set the value again where it now reads 1.0.
Malformed full-text syntax gets one safe retry. A failed expert MATCH
expression is now re-normalised through the bare sanitising path and retried once
before the FTS arm gives up, so a query that previously returned no keyword hits
may now return some. Every failed first attempt increments the read-only
fts_match_failure_count counter.
Configuration validation is uniform. Invalid values in supported config
sections are now rejected consistently whether the store is built from YAML or
through the corresponding typed construction path — both raise ConfigError.
Fix an invalid legacy value rather than relying on a path that previously skipped
validation.
Enabled hygiene gains conservative wall-clock guards. Memory hygiene is still
off by default, but for a store that already enabled it in 0.5 the omitted new
fields resolve to a seven-day minimum inactivity age before archival and a 30-day
wall-clock restore window before permanent GC, and archival additionally requires
an active usage-history signal in the candidate pool. Existing hygiene archives
with no archived_at fail closed and are not auto-GC’d while the wall-clock
window is active. Review the defaults in
Configuration → hygiene_policy before the first
0.6 hygiene run.
Custom embedding providers must expose a public dimension
If you pass your own embedding provider, check that it exposes dimension as a
public attribute or property. The protocol always required it, but 0.5 read it at
exactly one site off the query path — so a provider that kept the value privately
(self._dimension, with no public property) worked for as long as nothing called
verify_embedding_model().
0.6 reads it before every vector search that has to ask the provider, and such a
provider now raises EmbeddingProviderContractError naming the provider class
and the missing member.
A configured sqlite-vec backend declares the dimension on its own vec0 table
and is consulted first, so that configuration never reaches the provider read.
The fix is to expose the value:
@property
def dimension(self) -> int:
return self._dimension
The check is lazy: constructing a store with such a provider still succeeds,
and a store that never searches by vector is unaffected. Call
verify_embedding_model() after construction if you would rather fail at
startup — it raises the same typed error.
What is additive
The new edge-metadata, derived-record, cycle-provider, and transaction-time recency surfaces are additive and opt-in. Migrating a store does not enable automatic derivation or a cycle provider.
Cutover checklist
- Back up the database (checkpoint the WAL first).
- Stop the 0.5 workers.
- Upgrade application dependencies to
engrava>=0.6. - If you run the standalone MCP server, upgrade
engrava-mcpto 0.6 in the same step — 0.5.x pinsengrava<0.6and will otherwise conflict. See MCP Server. - Run one 0.6 process, or
engrava migrate, and let the migration complete. - Start the remaining 0.6 workers.
- Re-check any code that catches
ValueErroraround vector search, and any custom embedding provider’sdimension. - If you archive thoughts, decide where you now want
include_archived=True. - If you ever set an edge’s
decay_multiplierto0.0, re-check those edges — a 0.5.x update may already have replaced the value with1.0.
Coming from 0.4
0.4 → 0.5 is a separate hop — the MCP server moved out of engrava in that release — and it is still documented in Upgrade to 0.5. Run that upgrade first, then work through the checklist above.