What’s new
Recent changes to InvariantDB, and a summary of what the current release includes, grouped by area. This page describes releases up to 1.4.3.3. It was updated on 17 October 2026.
On this page
Recent changes
Changes in releases 1.3.9, 1.3.10, 1.4, 1.4.1, 1.4.2, 1.4.2.1 to 1.4.2.5, 1.4.3, 1.4.3.1, 1.4.3.2 and 1.4.3.3 (October 2026), and in September 2026.
New graphs store their history compressed
A graph created from 1.4.3.3 on stores its history compressed from its first write, so it takes less disk. Answers, reads at a past time, offsets, hashes and the audit chain are the same as for an uncompressed graph. Existing graphs are not changed by the upgrade. On a self-hosted server, a graph created by 1.4.3.3 with the default settings opens on 1.4.3 and later releases only; see Self-hosted servers below.
Security fix: recorded agent runs and /cases
In 1.4.3.2 and earlier, POST /graphs/{name}/agent recorded every run (the question, the model’s text and every tool result) in one memory graph shared by the whole server, and GET /graphs/{name}/cases and /cases/{caseId} served those records to any credential admitted to the target graph. Because a tool result holds what the recording credential could read, another credential of the same graph could read values its field read rules hide, and the recorded user id was taken from the request body. From 1.4.3.3 recording is off by default. Where a self-hosted operator turns it on, runs are kept per owner tenant, and /cases serves a run only to the credential that recorded it (same subject, tenant, session, roles and purpose) or to a global administrator holding the same roles; any other caller gets 404 and does not see the run in the list. A userId in the request body other than the credential’s own subject is refused with 400 AGENT_USER_ID_FROM_CREDENTIAL. db.aclEvents and the MCP tool acl_events without a principal filter now return every event the caller may see: all events for an administrator, the caller’s own events otherwise. A caller that is not an administrator sees only its own events, also when it names another principal; before, a call without a filter returned every principal’s events to any caller. We found no use of these routes on the hosted service in the 14 days before the fix, and no recorded runs stored there.
Natural-language, investigation and agent routes retired
POST /graphs/{name}/nlq, POST /graphs/{name}/investigate and POST /graphs/{name}/agent now answer HTTP 410 with subcode FEATURE_RETIRED (details.route names the route) after authentication, and call no model. Natural-language questions and agent runs move to the knowledge-graph product; run Cypher with POST /graphs/{name}/cypher. The cases routes stay for reading and are deprecated. remember, recall and save_document are not affected. A self-hosted server can serve the three routes again with a server setting until they are removed, no later than the announced date of 1 December 2026.
Full-text search: several fields, filters, matched words, excerpts, pages
db.fulltext.query takes a list of properties, or a map of property to weight ({title: 2, body: 1}), in place of one property; each needs a declared index, and a hit’s score is the weighted sum of its score in each field (new column fieldScores). New options: filter and nodeIds restrict the hits before ranking, so a page holds the first k hits that pass, with the same scores as without the filter (filter_statistics: 'filtered' scores as an index of the passing documents); at a past time the filter is checked on each node’s version at that time. matched_terms: true adds the columns matchedWords and matchedTerms; excerpt adds the hit’s best passage with the matches marked; group_by returns one row per value of a property (for example the best page of each document) with group and groupHits. POST /graphs/{name}/search/fulltext and the MCP tool search_fulltext take the same options. A query that uses none of them answers as before. A read at a past time of an index declared with history: 'all' no longer indexes again the text of nodes whose text has not changed since then, so such reads after many changes to other properties answer instead of being refused with FULLTEXT_QUERY_TOO_EXPENSIVE, with the same answers. See the Cypher reference.
Hybrid search: fusion, weights and reads at a past time
The options map of db.hybridSearch takes fusion ('rrf', the default, or 'score': each side’s scores are put on 0 to 1 and summed with the weights), weights ({bm25, vector}), normalize ('minmax' or 'max'), and at_valid and at_recorded to read the label at a time. A filter or nodeIds at a past time is now answered, checked on each node’s version at that time; through 1.4.3.2 it was refused with UNSUPPORTED_FEATURE. With index: 'declared', bm25Prop may be a list of properties or a map of weights. A call without the new options answers as before, with the same scores. See the Cypher reference.
Counts are no longer refused at the row budget
A RETURN of count, sum, avg, min or max (with or without DISTINCT, with or without grouping keys) after the statement’s last MATCH is computed as the MATCH produces its rows when the MATCH starts from a scan of a label or of the whole graph, with or without WHERE, AT RECORDED, AT VALID or AS OF. The rows are not kept, so MATCH (n) WHERE n.id IS NOT NULL RETURN count(*) or MATCH (n) AT RECORDED t RETURN count(*) on a graph with more nodes than the server’s row budget answers the count instead of 400 BUDGET_EXCEEDED. The statement’s time limit still applies. Relationship counts at a time without a type (MATCH ()-[r]->() AT RECORDED t RETURN count(*)) are much faster. The answers are those of 1.4.3.2 wherever it answered. See the Cypher reference.
CREATE INDEX on a large label returns at once
On a label with 100,000 nodes or more, CREATE INDEX registers the index and builds it in the background: the statement returns with the info warning INDEX_POPULATING (details.build_id, details.value_type), CALL db.listIndexes() shows the index as populating until it is online, and reads of the label answer as before meanwhile. A CREATE INDEX sent while another build runs is queued. Before, the request waited for the whole build. db.listIndexes() yields a new last column, value_type (int, string, float, bool), and HEAVY_OPERATION_IN_PROGRESS refusals carry retry_after_ms. See the Cypher reference.
Declared column files (opt-in)
CALL db.createColumn('node', 'Message', 'creationDate') declares a column on one property of a label (or 'relationship' and a relationship type). After the next full maintenance pass, a step that reads only declared properties of many records (a filter, an aggregate, a sort key) takes them from the column file; records written since then are read from their records. The answers are the same with and without columns. Reads at a past time do not use them. db.listColumns() and db.dropColumn() list and remove declarations. Nothing changes for graphs without a declared column. See the Cypher reference.
Keyed reads at a time keep up with a steady writer
A read at a time cut pinned to an indexed key (MATCH (c:Case {id: $k}) AT VALID t) answered from the label’s key-history index now catches up with the history written since the index was built, instead of falling back to a full read of the history while writes keep coming. When the index cannot answer, the statement carries the info warning TEMPORAL_KEYED_INDEX_DECLINED, and a TEMPORAL_READ_LIMIT refusal has details.keyed_decline (label, property, reason).
Backups and clones are complete
A file that a write created for the first time while a backup or a clone was taking its cut (for example the catalog of a graph’s first index or constraint, or a first access policy) is now in the archive. Before, the archive held the write but not that file.
Self-hosted servers
Operators of self-hosted servers should read the release notes shipped with their bundle before upgrading to 1.4.3.3. Every new default has a setting that restores the 1.4.3.2 behaviour. The on-disk format is that of 1.4.3 to 1.4.3.2, and a rollback to 1.4.3.2 is a binary swap. Graphs created by 1.4.3.3 store their history compressed: 1.4.3 to 1.4.3.2 open them, but 1.4.2 and earlier refuse them until they are converted back with the downgrade tool while the server is stopped, which needs free disk for the whole uncompressed history. To keep a rollback below 1.4.3 open for new graphs, turn off compression of new graphs before upgrading. After its first rollover a compressed graph’s history file is a sparse file; copy graph directories with a tool that keeps sparse files (for example rsync -S without --append, or cp --sparse=always). Existing graphs are converted only when the operator asks, in the background.
Query planner on by default
Read statements are now planned from statistics the server keeps for each graph: where a pattern starts, which index it uses, the order of consecutive MATCH clauses, and from which end a relationship between two bound nodes is checked. The same statement returns the same rows. What can change is anything that depends on the order rows are read in when the statement does not fix it with ORDER BY: the order of rows, which rows a LIMIT without ORDER BY keeps (or which of the rows tied under the ORDER BY keys), the order of a collect() list and so head(), last(), [i] and slices of it, and which of two equal grouping keys (2 and 2.0) names a group. A statement that needs an order must say ORDER BY on a unique key. Statements that write are planned as before. A graph plans as on 1.4.3.1 until its statistics are ready; they are built in the background after the graph opens, and no statement waits for them. See the Cypher reference.
Wrong-type size(), properties() and keys() answer ARGUMENT_TYPE_MISMATCH
size(x) where x is not a string, a list or null, and properties(x) or keys(x) where x is not a node, a relationship, a map or null, now answer HTTP 400 ARGUMENT_TYPE_MISMATCH with details.expected and details.got (for example INTEGER) when the value’s type is known only when the statement runs (for example size(n.age) on an integer property). On 1.4.3.1 they answered 400 UNSUPPORTED_FEATURE with a message saying the function was not in the standard library. size() of strings, lists, stored float lists and variable-length relationship variables, and size(null), are unchanged, and so is the 400 SEMANTIC_ERROR for a value whose type is known before the statement runs (size(1), a node variable). properties(x), keys(x) and x {.*} also leave out the reserved names (_valid_from, _seq and the others) when a record stored a value under one of them before the write guards, so properties(x)[k] equals x.k for every key. Code that matches on the old subcode should match on ARGUMENT_TYPE_MISMATCH. See the Cypher reference.
Paging contract
MATCH (n:L) RETURN … ORDER BY n.p [DESC] SKIP s LIMIT k, with or without WHERE, returns at every moment the rows that a sort of the whole label returns, including nodes written since the last maintenance pass, before and after maintenance and restarts, and at a time cut. Paging through a label on a unique key with no writes in between returns every node exactly once. This was already the behaviour of 1.4.3.1; it is now checked by tests and documented. See the Cypher reference.
Vector search: large k, partial rescoring, YIELD exact
Exact searches with a large k over a label kept in a vector file read fewer full vectors, in batches, with the same answers bit for bit. gds.vectorSearch reads from each hit’s record only the properties the statement uses (RETURN node.id, score reads id), and a filter or nodeIds that keeps a small share of the label is also ranked from the vector file’s 8-bit codes. New, opt-in: {rescore: r} with r below k returns the exact first r rows, then the next best rows by their 8-bit score, with the new column exact false on those rows and the warning VECTOR_PARTIAL_RESCORE. Without rescore every row has its exact score and exact is true (exact refers to the score; approximate HNSW searches stay approximate). PROFILE shows the vector search’s operators and what it read. See the Cypher reference.
Bulk vector writes no longer stall searches
A label’s vector file is now brought up to date in the background while writes come in, instead of when the next search finds it out of date. A search that cannot read the recent changes before its time limit no longer runs into QUERY_TIMEOUT: it waits for the running update when that ends sooner, or is refused at once with the retryable 503 VECTOR_FILE_REWRITE_PENDING (details.reason overlay_over_deadline, with Retry-After). It never returns a partial answer. See Known limitations.
Relationship property indexes
CREATE INDEX FOR ()-[r:TYPE]-() ON (r.prop) declares an index on one property of one relationship type, and DROP INDEX FOR ()-[r:TYPE]-() ON (r.prop) removes it; db.listIndexes() lists it with type relationship_range. A MATCH … WHERE r.prop > $x (also =, <, <=, >= on integers, = on strings) over relationships of that type is answered from the index, with the same answers as without it. Nothing changes for graphs without such an index. See the Cypher reference.
FOR PORTION OF corrections
FOR PORTION OF VALID FROM <from> [TO <to>] before a MATCH … SET or REMOVE applies the change to that valid interval only and keeps the values outside it and the other values of later versions. This is the correction under a later-dated version that 1.4.2.5 to 1.4.3.1 refused with WRITE_WOULD_REPLACE_LATER_VERSIONS; a FOR PORTION OF statement is never refused for that. A statement the form does not cover is refused with the new subcode PORTION_WRITE_UNSUPPORTED and nothing is written. Statements without the prefix run as before. See the Cypher reference.
Multi-statement transactions (off by default)
The server can run several Cypher statements as one transaction: every statement’s writes are applied, or none are, also across a crash, and other clients see none of them before the commit. They are off by default and are not enabled on the hosted service; there the transaction routes answer 404 TRANSACTIONS_DISABLED. Each Cypher statement is already all-or-nothing on its own. See the Cypher reference.
Self-hosted servers
Operators of self-hosted servers should read the release notes shipped with their bundle before upgrading to 1.4.3.2. The planner and the new vector-file defaults each have a setting that restores the 1.4.3.1 behaviour. Transactions, parallel execution inside one statement and the new recovery behaviours for damaged logs are off by default. The on-disk format is that of 1.4.3.1, and a rollback to 1.4.3.1 is a binary swap.
Linked memories in recall (on by default)
When a search names a person, place or thing that saved facts are about (I, me and my name the user), recall and recall_as_of follow those facts, and the facts of the subjects of the top facts, one or two steps to other facts that share a topic word with the search or are as close in meaning as the best facts. The memories that state those facts and were not already returned follow the others, with match: "linked" and via (the entity, the number of steps and the fact that linked it): at most 12, within linkBudget tokens (default 2000). search.links says when this happened. Most linked memories widen what a reader sees rather than answer the question. Send links: "off" for the answer of 1.4.3. See the MCP server page.
Group commit (on by default)
Writes to one graph that arrive together share one disk sync; a write is acknowledged after the sync that covers it, as before. Write throughput with many concurrent writers is higher. Concurrent reads no longer see the writes of a write statement before it has committed: on 1.4.3, while a multi-clause write statement ran, a concurrent read could see an intermediate state, and a later failure removed what it had seen. When a write that shared a disk sync cannot be completed (for example, the disk is full), it and the writes that shared its sync are refused together, and nothing of them is kept.
Filtered vector search on large indexed labels (on by default)
Where a label’s vectors are searched through an approximate (HNSW) index and the label also has an index on an integer, string or boolean property, the server builds in the background a separate vector index for the values of that property that have at least 1,000 vectors (within limits on their number and their share of the label). A filtered search whose eligible nodes are mostly one value’s nodes (WHERE d.category = $c, the filter of gds.vectorSearch or db.hybridSearch, or a pattern or node list that selects the same nodes) searches that value’s index when that is estimated to be cheaper. Such a search is approximate, with the recall target of other filtered approximate searches (about 0.99 recall@10 by default), so a filtered search that 1.4.3 answered by scoring every eligible node exactly may now be approximate. Nodes written or changed since the index was built are scored exactly. Searches that are exact by contract stay exact: Cypher searches with at most 5,000 eligible nodes, exact plans and flat vector indexes. See the Cypher reference.
Embedding model of new graphs; Cohere Embed v4 on the hosted service
A graph now records its embedding model when it is created. Graphs created before 1.4.3.1 keep their model; nothing about them changes. On the hosted service, graphs created from 1.4.3.1 on embed with Cohere Embed v4 at 1,536 dimensions: memories, facts, document passages, search text and properties declared for embedding. Their server-made vectors have 1,536 dimensions, so a precomputed query vector of another dimension is refused there. That text is sent to Cohere’s API to compute the embedding; Amazon Bedrock may also be used to run the same model. See the Privacy Policy. A vector property keeps the model it was built with. Searches now embed the query text as a query for models that treat queries differently from stored text (Cohere and Voyage), which raises recall on those models; stored vectors are unchanged. CALL db.memory.reembed(model) (graph administrators only: role admin, operator or tenant_admin) moves a graph’s memories to another model in a background job and switches when it is done; recall keeps using the current model meanwhile. CALL db.memory.reembedStatus() shows the graph’s memory model and the job.
Administrator keys bound to their tenant (hosted service)
On the hosted service, a credential with role admin or operator that was issued to a customer tenant now acts as tenant_admin of that tenant on every request: it reaches its tenant’s graphs, keys and running statements, and is refused on the routes reserved for the service operator. Before, such a key acted as an administrator of the whole service.
30-day retention of traces and job records
Request traces, copies of running statements written when the server stops, and the records of ended background jobs (with their input, result and error) are deleted after at most 30 days. GET /jobs/{job_id} and the vector export status, download and delete routes answer 404 for a job that ended more than 30 days ago; a vector export’s files are deleted with its record. Expired idempotency-key answers are removed from disk within about a week, also on a graph that receives few writes.
As-of reads on a large history
A read at a time cut pinned to an indexed key (MATCH (c:Case {id: $k}) AT VALID t, or AT RECORDED, AS OF, atTime, WHERE c.id = $k) takes its candidates from the property index and a key-history index built with the label’s as-of index, and, once that index exists, answers with the same rows instead of being refused with TEMPORAL_READ_LIMIT (info warning TEMPORAL_KEY_HISTORY_INDEX_USED). db.buildBitemporalLabelIndex(label [, {wait, background, full}]) runs as a background job: it returns within wait milliseconds (default 5000) with jobId, status and progress, continues after a restart, and db.bitemporalLabelIndexStatus() lists the builds. A label read refused for lack of a usable index starts a build of that label, and the refusal names the job. MATCH (n:L) AT VALID t WITH n LIMIT k without a WHERE stops resolving nodes once it has k rows when the label’s as-of index serves the read.
Bitemporal fixes
A node or relationship deleted with a future AT VALID time is returned by reads without a time until that time, by every form of read (before, the answer depended on the form of the read and on unrelated data). The MATCH of a write statement does not find such an entity before that time; write it again with an AT VALID time. db.labelCounts(), db.graphCounts(), the counts of GET /graphs/{name}/schema and of the MCP tools schema_info and describe_memory count what is valid now, as MATCH (n:L) RETURN count(n) does. MERGE … ON CREATE SET writes one version for one create in most cases, so db.history shows one row (see Known limitations). A backdated write is no longer refused because a later version holds a property set to null or removed. Relationship upserts by string id (import jobs, document ingest) follow the rule for writes that start before the current version, as updateEdgeProperties does.
Full-text search: refusal subcodes and messages
Refusals of a declared-index option on POST /search/fulltext, POST /graphs/{name}/search/fulltext and the MCP tool search_fulltext (FULLTEXT_INDEX_NOT_FOUND, FULLTEXT_OPTION_INVALID) carry the code as subcode (HTTP) and data.subcode (MCP); the messages are unchanged. Refusal messages and the procedure descriptions that db.listProcedures() returns for full-text search no longer name server settings, and the description of db.createFulltextIndex lists history: 'current' | 'all'. GET /openapi.yaml on the server parses as YAML again and carries version 1.4.3.1.
Faster reads
More read statements run on the query engine: shortestPath with both ends found in the same MATCH, UNWIND, pattern comprehensions, EXISTS { }, COUNT { } and COLLECT { }, and named paths. Statements read only the relationship and node properties they use, label checks of a hop’s target use the label index, and size() of a fixed pattern between two nodes the row holds is counted from their relationships. Answers are the same. A size([…]) count that was refused for its row or byte budget can now answer, and a statement can need less query memory than before.
Self-hosted servers
Operators of self-hosted servers should read the release notes shipped with their bundle before upgrading to 1.4.3.1. Linked memories, group commit and filtered vector partitions each have a setting that turns them off, and the 30-day retention has settings for shorter periods. The on-disk format is that of 1.4.3. New graphs record the default local embedding model unless the server’s embedding catalog names another model for new graphs.
Read statements run on the query engine
Read statements run on a planned query engine where it plans them, and on the interpreter otherwise; nothing in a statement chooses the engine. Where both answer, the rows are the same. Statements that write, EXPLAIN ANALYZE and PROFILE, reads with a time clause, UNION, subqueries and the statements of a credential that an access policy or a field read rule restricts are among those that run on the interpreter, as on 1.4.2.5. Two differences can be seen: the engine holds fewer rows at once, so some reads that 1.4.2.5 refused with BUDGET_EXCEEDED (row budget) or QUERY_MEMORY_BUDGET now answer; and the rows of a statement without ORDER BY, which come in no defined order, can come in another order. Statements that read many records read them together, in id order, and a pattern check between two nodes the row already holds (WHERE (a)-[:KNOWS]->(b)) is answered from their relationships, with the same answers. See the Cypher reference.
Full-text indexes
db.createFulltextIndex(label, property) declares a full-text index that stores word positions and sentence and paragraph boundaries. db.fulltext.query answers phrases, AND / OR / NOT, prefix and wildcard words, words within n words, and words in the same sentence or paragraph, in four syntaxes (words, legal terms and connectors, lucene, fts5), with an exact total, pages by cursor, and snippets that quote the stored text with byte, character and UTF-16 offsets. Indexes are built in a fixed amount of memory, so labels of any size can be indexed, and follow writes: a write is in the answer of the next query. Text in every script is indexed. An index declared with history: 'all' also answers reads at a valid or recorded time. A document whose node a field read rule hides from the caller is left out before ranking, also from total and the scores. On a label and property with a declared index, POST /graphs/{name}/search/fulltext, the MCP tool search_fulltext, POST /graphs/{name}/search (mode bm25) and the keyword side of memory recall answer from it; on the first two, send index: 'adhoc' to keep the earlier answer. gds.fulltextSearch, the search routes and the MCP tool take an analyzer (unicode-v2 indexes every script), and the 9th argument of db.hybridSearch takes analyzer or index: 'declared'. See the Cypher reference.
Faster exact vector search over large vector files
Where a label’s vectors are kept in a large vector file, the server also writes 8-bit codes of the vectors next to it. Exact searches over the file (gds.vectorSearch on an exact route, ORDER BY vector.similarity(...) LIMIT k including searches narrowed by a pattern, the vector side of db.hybridSearch) rank from the codes and read a row’s full vector only when the codes cannot rule it out. The answers are the same, bit for bit. A vector file written before 1.4.3 gets its codes when it is next rewritten.
Indexes and field read rules (security fix)
For a caller that a field read rule denies a property, a statement no longer uses that property’s index to find the nodes of a pattern. The rows were already the same, but EXPLAIN, EXPLAIN ANALYZE (rows_out, index_used) and stats.nodes_read depended on values the caller may not read. For such a caller, db.listIndexes() returns entity_count null on that property’s rows, and a statement that calls vector.* gets no eligibility_size_estimate from a predicate on it. A statement that filters only on such a property reads every node of the label. Other callers see no change. See the Cypher reference.
More statements stop when the connection closes
Statements sent through /graphs/{name}/sql, /graphs/{name}/graphql, /graphs/{name}/nlq, /graphs/{name}/investigate and the MCP tools that call a procedure (remember, record_episode, replay_episodes, belief_lineage, property_provenance, derived_from, acl_events, subject_export, destroy_subject_key, list_indexes) stop when their HTTP connection closes, as /cypher statements do since 1.4.1, and are listed by GET /admin/queries and can be cancelled. Procedures that read in proportion to the data (db.changes, db.delta, db.history, db.propertyHistory, db.propertyProvenance, db.subjectExport, db.replayEpisodes, db.exportDecisionTrail and others) stop while they read at a cancel, a closed connection or the time limit.
Other changes
ORDER BY over a column that mixes integers above 253 with floats compares the exact values; before, the order of such rows could differ between runs or the statement could fail with an internal error. Memory recall is faster, with the same answers. On a server with many graphs, vector searches no longer drop the searched graph’s other vectors first when the server’s vector memory is full; the least recently used graphs give theirs up first. Requests use less server CPU.
Self-hosted servers
Operators of self-hosted servers should read the release notes shipped with their bundle before upgrading to 1.4.3. It lists every new setting and its default, including the switch that returns read statements to the interpreter. Features that are off by default in 1.4.3 are not described on this site.
Backdated writes that would replace later-dated versions are refused
A property or label write (SET, REMOVE, MERGE … ON MATCH SET, a relationship SET) that starts before the current version of its node or relationship is the value from its start on, so it replaces every version recorded for a later valid time. From 1.4.2.5, when that would change such a later version (lose one of its property values or labels, or bring back a property or label a later write removed), the statement is refused with 400 WRITE_WOULD_REPLACE_LATER_VERSIONS (MCP -32006) and nothing is written; the message names the entity, the later versions’ starts and what they would lose. This covers a backdated correction (AT VALID t MATCH (n) AT VALID t SET n.p = x) under a version recorded for a later time, and a write without AT VALID after a scheduled change (AT VALID '<future>' … SET). Before, the write was applied and copied the latest version’s other values into the corrected interval. A write that changes nothing of them is applied. A write without AT VALID is refused only because of a version that was dated in the future when it was written (a scheduled change); concurrent writers, retries and clock differences between writers never cause the refusal. On the hosted service the refusal applies; a self-hosted operator can choose to apply such writes instead (they then replace the later versions and warn WRITE_REPLACES_LATER_VERSIONS). See the Cypher reference.
A delete without AT VALID of a version that starts later is refused
DELETE, a relationship DELETE and DETACH DELETE without the prefix end the version at the time the statement runs. When the current version starts after that (it was written with a future AT VALID time), the statement is refused with 400 VALID_END_BEFORE_VALID_FROM (MCP -32006) and nothing is deleted. Before, it was accepted and the future-dated fact disappeared at every valid time. To withdraw a future-dated version, delete it with AT VALID '<its valid_from>'.
Where a version ends, with writes recorded out of order
A version’s end (_valid_to at AT VALID and AT RECORDED reads, validTo in db.history, db.propertyHistory, db.propertyProvenance and property_provenance, and the windows of OVER VALID) is the earliest valid start among the writes and deletes recorded after it. Before, it was the start of the first write recorded after it, so with writes recorded out of valid-time order a version could report an end that disagreed with what reads returned. Writes recorded in valid-time order report the same ends as before. db.propertyHistory and db.propertyProvenance merge equal values only where they touch in valid time.
At most 256 new labels per statement
A CREATE or MERGE that names more than 256 labels that are new to the graph is refused with 400 TOO_MANY_NEW_LABELS (MCP -32602) before it waits for the graph’s write lock; nothing is written. Labels that already exist do not count. The other write routes (/write, imports, MCP memory tools) apply the same limit to each call. Before, one statement with thousands of new labels could hold a graph’s writes for minutes.
Cypher changes
Writes to _seq are refused with RESERVED_BITEMPORAL_PROPERTY_WRITE, as for the other reserved names; a hidden _seq stored by an earlier release can be removed with REMOVE n._seq. substring(), left() and right() refuse a negative start or length with ARGUMENT_TYPE_MISMATCH, as Neo4j does. An unaliased map projection is named after its variable (RETURN d{.id} has the column d, not col_0); alias it to keep the old name. An unterminated block comment is refused with SYNTAX_ERROR; before, the rest of the statement was dropped. New: CALL { } and procedure calls inside a CALL { } body (nested up to 8 levels), isEmpty() and the one-argument btrim(s). CALL { } bodies that declare their own variable under the name of an outer one (UNWIND … AS t, WITH x AS t, YIELD t) run again; 1.4.2.3 and 1.4.2.4 refused them.
MCP: access-policy refusals carry their subcode
When a graph’s access policy refuses a statement, every MCP tool that runs Cypher answers -32007 with data.subcode ACCESS_DENIED or ACCESS_POLICY_SURFACE_UNAVAILABLE. It was -32603 without a subcode. Nothing ran, as before. trace_step (experimental, off by default) can confirm a step sent without ordinal by resending it with the returned ordinal. The cypher_query tool description now tells agents to pass idempotencyKey and resend the identical call with the same key after a timeout (text only).
Writes during maintenance
Three refinements to the 1.4.2.1 change, visible only when the server’s write backlog budget is used up: a request whose first write was applied is no longer refused partway through; a write accepted under the budget answers at once; and one waiting write statement no longer holds the graph’s write lock, so the statements behind it are answered at once, accepted or 503 GRAPH_BUSY (retryable, nothing applied).
A read without a time answers what is valid now
A value written with a future AT VALID time is returned by a read without a time from that date on; before, it was returned at once. Such a read carries the info warning CURRENT_READ_VALID_NOW. The MCP get_node_by_str / get_edge_by_str tools return the version valid now. Not changed: reads with a time, the MATCH of a write statement (it binds the current record), and credentials that a graph’s access policy restricts (they still read the current records). While a label or relationship type holds a version dated in the future, reads of it without a time do not use the count and index fast paths, and are slower; see Known limitations.
Version ends are reported
A version that a later write replaced reports its end: _valid_to and _txn_to at AT VALID and AT RECORDED reads, and validTo in db.history, db.propertyHistory, db.propertyProvenance, property_provenance and list_document_revisions. Before, a version replaced by a write without AT VALID read null. Stored history is unchanged. OVER VALID and OVER RECORDED BETWEEN return each version once, with its end, for the windows it overlaps. A plain write after a future-dated write starts from the value valid at its start.
Reserved time names everywhere
_valid_from, _valid_to, _txn_from, _txn_to, _version and _seq match in a node pattern’s map (MATCH (n:L {_valid_from: $t})) and read their value on a node or relationship held in a list, a map or a path (collect(n)[0]._valid_from, nodes(p)[0]). They matched nothing or read null there.
MCP: ISO-8601 times, JSON nulls, two document tools
property_provenance and list_document_revisions return validFrom, validTo and txnAt as ISO-8601, with the nanoseconds in new last columns; get_node_by_str / get_edge_by_str add ISO-8601 times to span; remember also returns eventId and savedAt. A null cell is JSON null (it was the string "null"). remember and record_episode accept a future validFrom (not with facts). recall_as_of with a query finds memories deleted after atTime. New tools list_documents and find_document. See MCP server.
New refusal codes for time-bounded writes
AT VALID '<to>' … DELETE with <to> before the version’s start answers 400 VALID_END_BEFORE_VALID_FROM (it was RUNTIME_ERROR). A write with a closed valid interval (AT VALID FROM x TO y, BETWEEN) answers the subcode CLOSED_INTERVAL_WRITE_UNSUPPORTED (it was SYNTAX_ERROR; status 400 unchanged).
Cypher semantics checked against Neo4j
Fixes from a differential test against Neo4j 5.26: CREATE with a pattern node bound to null is refused (it created an empty node); several ON CREATE SET / ON MATCH SET clauses on one MERGE all apply; a map projection of null is null; WITH … WHERE reads the new value of a renamed variable; WITH *, x AS y keeps x; ORDER BY, min() and max() order integers and floats exactly; float equality is exact (0.1 + 0.2 = 0.3 is false; compare computed floats with a tolerance); a CALL { } body sees an outer variable only through an importing WITH (otherwise 400 UNRESOLVED_VARIABLE); slices with a variable lower bound; upper() and lower(). Some statements that answered before are now refused. See the Cypher reference.
Faster vector search narrowed by a pattern
A narrowed vector search whose conditions keep at least half of a label, and one with a large LIMIT, read fewer records. The rows are the same.
Vector search narrowed by a pattern, a relationship property or a node list
MATCH <path> WHERE … RETURN … ORDER BY vector.similarity(r.p, $q) DESC LIMIT k scores only the nodes the pattern reaches, with the same rows as before, so the filter fields can stay on the nodes and relationships they describe. gds.vectorSearch takes a config map as its 5th argument: filter (conditions on properties) and nodeIds (the nodes the search may return) apply before the top k. db.hybridSearch takes {nodeIds: […]} as a 9th argument. New: vector.similarity.cosine and vector.similarity.euclidean (the Neo4j 5 names). See the Cypher reference.
Vector search during an index rewrite
A vector search of a large label whose vector data is being rewritten (after an upgrade or a re-ingest) is answered exactly from the label’s records, with the warning VECTOR_FILE_REWRITE_PENDING, instead of 503 until the rewrite finishes. A float range filter compares as WHERE does (NaN matches no bound; -0.0 equals 0.0).
Statement parsing is bounded
Subquery bodies (EXISTS, COUNT, COLLECT, CALL) nest at most 32 deep (400 EXPRESSION_TOO_DEEP), every parse has a step and time budget (400 PARSE_BUDGET_EXCEEDED), and a statement text over 2,097,152 bytes is refused (400 STATEMENT_TOO_LARGE). Before, a small, deeply nested statement could hold a CPU core for minutes.
Errors instead of silent answers
An ORDER BY key that cannot be evaluated (an unknown function, a wrong number of arguments, a type error) is refused with the error the same expression gives in RETURN; it was taken as null and the rows came back unsorted. db.derivedFrom and MCP derived_from resolve the node as db.linkDerivedFrom does (a storage string id, then an id property value) and refuse a reference that names no node with LINEAGE_NOT_FOUND. range() over 1,000,000 entries is refused with BUDGET_EXCEEDED.
Constraints and document saves
The quote hash of a required-relationship constraint is stored only on the relationships a rule covers. A document save that a rule refuses saves nothing: the ingest job fails with INGEST_REFUSED and MCP save_document answers saved: false, refused: true.
Other fixes
/, /livez, /healthz, /admin/version and /info report the release as version (it was 0.1.0); /livez and /healthz also return build_id. A comma pattern with its anchor in WHERE applies the anchor before it builds pairs. AT VALID … LIMIT reads that were refused with TEMPORAL_READ_LIMIT fall back to the read the form without LIMIT uses. POST /graphs/{name}/import accepts plain JSON values in updateNodeProperties and updateEdgeProperties.
Writes are accepted during full maintenance
Write statements are accepted while a graph is being saved or rewritten; the writes of that time are held in memory and in the write-ahead log and saved when the maintenance ends. On 1.4 to 1.4.2 a graph that kept receiving writes answered 503 GRAPH_BUSY to every write for the rest of the maintenance. 503 GRAPH_BUSY remains when all graphs of the server together hold more unsaved writes than the server’s budget, or under memory pressure; it is retryable and nothing was applied. Administrators can set when full maintenance runs for one graph (/admin/graphs/{name}/compaction-policy).
Search under field read rules
A node of the searched label can carry a second label whose field read rule hides the searched property from the caller. From 1.4.2 a search answers what it would answer if those nodes were not in the graph: they are not returned, they take no place among the best k, and they are not counted in keyword scores or in eligibility_size. This applies to gds.fulltextSearch, gds.vectorSearch, db.hybridSearch, the seeds of gds.semanticMatchExpand, the search routes, POST /graphs/{name}/memory/recall and the MCP tools search_fulltext, search_vector and recall. On 1.4.1 gds.fulltextSearch returned such a node with its id and score, and the other searches dropped it after ranking, so an answer could be shorter than k. For a caller with such a rule in force, the hits, ranks and scores can differ from 1.4.1. Other callers see no change.
Hybrid search with a filter
From 1.4.2, db.hybridSearch with a filter computes its keyword scores over the nodes the filter admits. A node outside the filter no longer changes the score or rank of a node inside it. With a filter, bm25_rank and the fused order can differ from 1.4.1. Calls without a filter are unchanged.
Idempotency keys on HTTP writes
From 1.4.2 write requests accept an Idempotency-Key header (1 to 255 visible ASCII characters): POST /cypher, /graphs/{name}/cypher, /execute, /graphs/{name}/execute, /write, /graphs/{name}/documents, and the job routes /graphs/{name}/import, /graphs/{name}/ingest and /graphs/{name}/import/from-s3. A resend with the same key and the same request returns the first answer with the response header Idempotent-Replayed: true and writes nothing; a job route returns the original job id. The same key with a different request answers 409 IDEMPOTENCY_KEY_REUSED, and a key whose first request is still running answers 409 IDEMPOTENCY_KEY_IN_FLIGHT. Keys are kept for 24 hours by default and belong to one graph and one credential. GET /graphs/{name}/idempotency-keys/{key} tells whether a keyed request committed. The MCP tools cypher_query (when the query writes), set_ontology and destroy_subject_key accept idempotencyKey, as remember, record_episode and save_document do from 1.4. Releases before 1.4.2 ignore the header. See the API reference.
Errors say whether to retry
From 1.4.2, HTTP error bodies carry retryable (true or false) and retry (retryable, terminal or ambiguous). MCP errors carry the same as data.retryable and data.retry, including errors that had no data before. ambiguous means the request may have been applied: resend it only with the idempotency key it was sent with.
Access control by label and relationship type
From 1.4.2 a graph can carry an access policy: roles with allow and deny rules for read, write and grant on the graph, a label or a relationship type. For a credential the policy restricts, a node or relationship it may not read is absent from Cypher reads, counts and paths, and a write the policy does not allow answers 403 ACCESS_DENIED. The db.access.* procedures read and change the policy; each change is recorded with the credential that made it, and db.access.why returns the rule behind a decision. A graph without a policy behaves as before. On this release a restricted credential is refused search, other procedures, temporal reads and the HTTP routes outside Cypher (403 ACCESS_POLICY_SURFACE_UNAVAILABLE). See the Cypher reference.
Required-relationship constraint, with an optional quote check
From 1.4.2, CREATE CONSTRAINT [name] FOR (c:Claim) REQUIRE (c)-[:SUPPORTED_BY]->(:Passage) refuses any write that leaves a node of the label without such a relationship: creating the node without it, adding the label, deleting its last such relationship, or deleting the node at the other end. With OPTIONS { quote: true } each such relationship carries quote, start and end, and the quote must equal the other node’s text at those positions. A violation answers 409 CONSTRAINT_VIOLATION and applies nothing. The rule is checked on current records on every write path; it is not checked at past valid times. See the Cypher reference.
The credential behind every write
From 1.4.2 every write of an authenticated request records the credential that made it as principal (for example apikey:<key id>), with or without a provenance object; 1.4.1 recorded it only when the request carried one. A request cannot set it. Read it with db.propertyProvenance, the new principal column of db.history, or the MCP tool property_provenance. It is null for unauthenticated writes, for writes made before 1.4.2 without a provenance object, for import and other background jobs, and for the legacy POST /write route. After a host failure the principal of the last writes before it can be missing. Any caller that may run these procedures on a graph sees the credential ids of its writers.
db.history returns two more columns
From 1.4.2 db.history also returns kind (created, updated or deleted) and principal. A CALL db.history(...) without YIELD binds eight columns instead of six.
History verification and size
From 1.4.2, GET /graphs/{name}/audit/verify on a graph that is taking writes no longer reports a record that is being appended as a break. It reports a record whose stored length disagrees with its content as a break, and a cut-off last record with the reason partial record at end of file. GET /graphs/{name}/status has a history object with the size of the graph’s history and the bytes appended since the graph opened and in the last hour.
Server information routes
From 1.4.2, GET /info, GET /engine_info and the MCP tool engine_info return graph_dir to admin credentials only; other callers get null, and /info returns auth: "enabled" to them. next_seq and current_snapshot_seq of the default graph are null for a caller that may not use the default graph. The MCP tool has a new field graph, the name of the graph the connection is bound to. /info, /engine_info and /stats answer 429 after too many requests without a valid credential from one address.
Health check: public and full form
From 1.4.2, on a server with authentication on, GET /healthz answers a caller without an admin credential with "detail": "public": status, version and component states, with no paths, sequence numbers or error text; embedder.default_unresolvable_reason is not included. An admin credential gets "detail": "full". Both forms answer 200, and a refused credential gets the public form. Health probes should send no credential.
Tenant admins and running statements
From 1.4.2, GET /admin/queries answers a tenant_admin key with the running statements of its own tenant; on 1.4.1 the route needed the admin role. The list is oldest first. A statement id is unique for the life of the server process, below 252, and carries no order. For a tenant_admin, POST /admin/queries/{id}/kill answers 404 for a statement of another tenant. On a multi-tenant server, setting or deleting a graph’s budget (POST and DELETE /admin/graphs/{name}/budget) needs the admin role. The MCP tool list_graphs lists only the connection’s graph for a credential that has neither a tenant nor an admin role.
Query profiles are returned to their owner
From 1.4.2, db.showQueryProfile returns a profile to the caller that ran the statement and to admin credentials. Another caller gets found: false.
Memory recall reads wider for counting and listing questions
From 1.4.2, recall and recall_as_of read wider when the question counts, totals, lists or compares (“how many”, “in total”, “list all”, “average”): up to 40 more matching memories (marked wide: true), the other current facts with the same subject and predicate, and items, a numbered list of the distinct matching events and facts in date order. An event stated in several memories is one item; each item says when it happened (happenedOn) and when it was mentioned (mentionedOn). New options: breadth (auto, normal, wide), items, breadthBudget (default 4000 tokens) and referenceTime. search.breadth says when a recall read wider. The question is read with English cue words, and the list can include items that do not answer it, so count the items yourself. Other questions are answered as on 1.4.1. See MCP server.
Episode payloads and salience
From 1.4.2, db.recordEpisode accepts a map or a list as payload and stores it as its JSON text; 1.4.1 refused them with ARGUMENT_TYPE_MISMATCH. db.reinforceSalience accepts the node itself: MATCH (n:Note {id: $id}) CALL db.reinforceSalience(n, 0.5) YIELD strId, newSalience RETURN strId, newSalience.
Run traces and a shared workspace (experimental, off by default)
1.4.2 adds nine experimental MCP tools (trace_start, trace_step, trace_end, trace_replay, task_update, task_claim, note_post, note_ack, verdict_post) and the routes POST /graphs/{name}/trace/{op} and POST /graphs/{name}/workspace/{op}. They record an agent run step by step, return a run and the memory it read or wrote as of a step, and let agents share tasks, notes and review results. They are served only where the server operator has turned them on; otherwise the tools are not in tools/list and the routes answer 404. See MCP server.
Row budget on every read
From 1.4.2 a statement that returns more rows than its row budget is refused on every read path. On 1.4.1 some simple reads returned results past the budget.
Self-hosted servers
Operators of self-hosted servers should read the release notes shipped with their bundle before upgrading to 1.4.2.
Exact label counts in one call
CALL db.labelCounts() YIELD label, count returns every label that has a live node with its exact node count, the latest writes included. GET /graphs/{name}/schema and db.graphCounts() count labels and relationship types with the latest writes included, and the counts of the MCP tools schema_info and describe_memory include the latest writes.
PROFILE, and plans that name the index
PROFILE <statement> is accepted and is the same as EXPLAIN ANALYZE. EXPLAIN names the index a read starts from for WHERE equalities, IN lists and ranges; tests that compare EXPLAIN text can change. Each response’s stats gains rows_materialized, row_budget and nodes_read.
As-of reads are complete or refused
A read at a time cut (AT VALID, AT RECORDED, AS OF, with or without LIMIT) that would have to look at more of the graph’s history than the server allows fails with HTTP 400, subcode TEMPORAL_READ_LIMIT, and returns nothing. 1.4 answered 200 with a warning and without the nodes deleted after the cut. Read one label at a time, or use a later cut.
As-of reads of one label, and label counts as of a time
The cost of MATCH (n:L) AT RECORDED t (or AS OF t) follows the history of label L, not of the whole graph. CALL db.labelCountsAsOf(t) YIELD label, count returns the node count of every label as of a recorded time. MATCH (n:L) AT VALID t RETURN … LIMIT k, the atTime parameter and the MCP tool recall_as_of return nodes deleted or relabelled after t when the live nodes do not fill the LIMIT.
Statements stop when the connection closes
A statement stops when its HTTP connection closes, on POST /cypher, POST /graphs/{name}/cypher, the explain routes and the MCP tool cypher_query. POST /cancel/{id} cancels a running statement of the caller (any statement for an admin) and answers 404 for an id that is not running or not the caller’s.
Valid credentials and the per-address limit
A request with a valid API key is no longer refused by the per-address limit that applies before authentication. A JWT, OAuth or capability token is exempt for 5 minutes after it last authenticated.
Comparisons with null and between numbers
A comparison with null, written as a literal or passed as a null parameter (WHERE n.p = null, n.p <> $v, n.p < null), matches no node; use IS NULL and IS NOT NULL. 1.4 treated = null as IS NULL and <> null as IS NOT NULL on MATCH … WHERE … RETURN … LIMIT k. Integer and float values compare exactly in MERGE and in range reads (3 = 3.0).
Non-finite floats are returned as strings
A float that is infinite or not a number is returned as the string "Infinity", "-Infinity" or "NaN". 1.4 returned null, the same as a missing value. On input the tagged form {"float": "Infinity"} is read as a float; a bare string parameter stays a string (use toFloat($p)). Finite floats are unchanged.
A repeated label is stored once
CREATE (n:A:A) stores the label once: labels(n) returns ["A"], and counts of A count the node once.
Hybrid search: empty filter, and search at a valid time
db.hybridSearch with a filter that matches no node returns no rows with the warning HYBRID_FILTER_EMPTY_RESULT; 1.4 refused the call. Under an AT VALID prefix on a read-only statement it ranks the nodes valid at that time; a filter argument is refused with the prefix, and the warning HYBRID_VECTOR_PARTIAL counts the nodes that have no vector and are ranked by keyword only.
Faster multi-hop reads, pages and vector search
Multi-hop reads that count nodes or pass them through without reading their properties (count(m), id(m), WHERE f <> p) are faster on large graphs. MATCH (n:L) WITH n LIMIT k stops reading the label once it has its rows, keyset pages (WHERE n.p > $after ORDER BY n.p LIMIT k) read from the index, and an unlabelled MATCH (n) WHERE n.p = $v uses property indexes. The row budget counts a row once, so statements refused for the budget on 1.4 may now run. Exact and filtered vector search are faster; the answers of exact search are unchanged.
Bulk vector load
POST /graphs/{name}/import/vectors queues a background job that creates one node per row, each with its string id, property values and one vector property, from a binary request body. It answers 202 with the job id. See the OpenAPI specification for the body format.
CREATE INDEX before the first value
CREATE INDEX on a label that has no value for the property yet takes the type of the first value written, and returns the warning INDEX_TYPE_PENDING. CREATE INDEX again on an index that holds no value because every stored value has another type rebuilds it with that type, with the warning INDEX_RETYPED.
Memory tools: valid time, facts, and search by meaning
remember and record_episode accept validFrom, the time the fact became true. remember accepts facts, structured facts saved with the memory; a single-valued fact replaces the earlier value from its validFrom. recall searches by keyword and by meaning and returns memories, document passages and facts. recall_as_of runs the same search over the memories valid at atTime. A memory whose embedding cannot be computed is saved, and the result says the embedding is pending. See MCP server.
Health check reports a failed embedding model
GET /healthz reports embedder.state as load_failed, with the reason in embedder.default_unresolvable_reason, when the default embedding model failed to load; 1.4 reported ready.
Flush answers the published snapshot number
POST /graphs/{name}/flush returns the sequence number of the graph’s published snapshot as snapshot_seq, also when there was nothing to flush. A client that compared it with a write’s sequence number sees a smaller number.
Self-hosted servers
Operators of self-hosted servers should read the release notes shipped with their bundle before upgrading to 1.4.1.
All-or-nothing write statements
A write statement applies all of its writes or none: an error, its time limit or a cancel leaves nothing of it. Write statements on one graph run one at a time; a write that waits too long answers 503 WRITE_LOCK_TIMEOUT with nothing run. Later clauses see the statement’s earlier writes, SET items apply in order, and writes inside CALL { } and FOREACH are visible to later steps.
Constraints on every write
UNIQUE (one property or a combination), NOT NULL, NODE KEY and property type constraints are checked on every write path, including MERGE, imports and MCP tools. A violation answers 409 CONSTRAINT_VIOLATION and applies nothing. CREATE CONSTRAINT checks existing data and accepts IF NOT EXISTS; db.validateConstraints() checks constraints created on earlier releases.
Both time axes in one read
AT RECORDED r AT VALID v returns the version that was current on both axes.
Integer times must be nanoseconds
An integer time below 1015 in a temporal clause is refused with TEMPORAL_UNIT_AMBIGUOUS, so AT VALID timestamp() fails instead of reading January 1970.
Time limits and memory and disk guards
A statement past its time limit answers 408 QUERY_TIMEOUT and keeps nothing. A self-hosted server stops statements after 240 seconds by default; X-Query-Timeout-Ms can ask for up to 290 seconds. A statement that would exhaust server memory is stopped with 503 MEMORY_PRESSURE, and writes are refused with 503 DISK_SPACE_LOW when free disk space is below the server’s floor; reads keep working. A cancelled statement answers 499.
Retired vector index types
Quantized, segment and disk vector indexes are no longer used for search; searches answer exactly from the stored vectors where the label is small enough, and otherwise with VECTOR_TIER_REFUSED. The procedures that build these index types are refused. Rebuild such an index as flat or hnsw.
FOREACH applies every element
A FOREACH body that updates a property from its own earlier value applies every element: FOREACH (i IN range(1, 5) | SET c.n = c.n + 1) adds 5. Builds from 18 September 2026 through 1.3.10.1 kept one element’s update and returned no error; check data written by such loops.
Re-using a dropped graph’s name
A graph created under the name of a dropped graph starts empty and never serves the dropped graph’s data. Jobs queued for the dropped graph fail instead of running against the new one. Creating, restoring or cloning under a name dropped moments ago can answer 503 GRAPH_BUSY until the dropped graph’s last work ends; retry.
Keyword search warns about unmatched words
Keyword search matches only words made of the letters a–z and the digits 0–9. When a query has other letters or digits, POST /graphs/{name}/search/fulltext, POST /graphs/{name}/search, POST /graphs/{name}/memory/recall and the MCP tool search_fulltext return the warning FULLTEXT_TOKENIZER_LIMITED, and the MCP tool recall returns a note saying so, because words written with them are not matched.
Idempotent MCP writes
remember, record_episode and save_document accept an idempotencyKey; a repeated call with the same key returns the first result with replayed: true.
Integer overflow is an error
Integer arithmetic that leaves the 64-bit range, and integer division by zero, fail with subcode ARITHMETIC_ERROR (HTTP 400) instead of returning a wrapped value. A statement that hits a server defect is stopped with INTERNAL_ERROR (HTTP 500); the server and other statements keep running.
Correct answers after recent writes
AT VALID reads of a label include nodes written since the graph was last maintained. Pattern hops after a relabel, covering-index reads and whole-node rows answer for the current data. Vector and keyword search over HTTP and MCP answer for the current data; the first vector search after a write can take longer (up to a few hundred milliseconds on the largest labels kept in memory).
S3 import limited to the server’s bucket
GET /graphs/{name}/import/upload-url and POST /graphs/{name}/import/from-s3 use only the server’s configured bucket and region, accept keys under imports/{name}/ only, and need a credential that may write to the graph. Refusals carry IMPORT_* subcodes; vector export uploads follow the same rules with EXPORT_* subcodes and exports/{name}/.
Duplicate nodes in graph algorithms
PageRank, Leiden and Louvain accept foldInto, which counts a node marked as a duplicate as the node it duplicates.
Self-hosted servers: secure defaults
A self-hosted server with API keys or a token verifier configured requires authentication unless the operator turns it off, and a server without authentication refuses to listen on a network address unless the operator allows it. See the self-host documentation.
Default graph reserved for operators
Routes without a graph name (POST /cypher, POST /mcp, /write, /execute, /search/* and others) serve the server’s default graph, which only operator credentials may use. Other credentials receive 403 DEFAULT_GRAPH_FORBIDDEN (MCP: JSON-RPC -32008). Use /graphs/{name}/....
Graph status and bounded waits while a graph opens
GET /graphs/{name}/status reports whether a graph is open, opening, failed to open or not open, without opening it. A statement sent while the graph opens waits a bounded time and then receives 503 GRAPH_OPENING with Retry-After; nothing was run.
Query memory admission
A statement that needs more query memory than the server has free is stopped before it writes anything, with 503 QUERY_MEMORY_BUDGET and Retry-After.
Faster sorted pages and restarts
ORDER BY with LIMIT over one label reads the property index of the sort key, including the latest writes. Graphs open faster after an unclean stop, and write latency no longer grows during long runs of writes.
Write provenance
Cypher writes accept an optional provenance object with actor, source, reason, requestId and confidence (0.0 to 1.0). Send it as a body field or X-Provenance-* headers over HTTP, or as the provenance argument of the MCP cypher_query tool. The server adds the calling credential as principal; from 1.4.2 it records principal for every write of an authenticated request, with or without a provenance object. Read it back with db.propertyProvenance.
Documents over MCP
save_document saves a document under a stable uri as its current version and keeps earlier versions in history. get_document returns the current version, or the version that was current at a given time. list_document_revisions lists the saved versions.
Extraction categories
set_ontology sets the categories and relationship types that fact extraction uses on a graph. describe_memory lists them with the number of items in each category.
OAuth sign-in for MCP clients
MCP clients that support OAuth can connect to https://invariantdb.com/graphs/{graph}/mcp without storing an API key. You approve the connection by pasting a gq_ key on an approval page; the client receives a short-lived access token that works only on the MCP endpoints.
MCP protocol versions and SSE responses
The MCP server accepts protocol versions 2025-06-18, 2025-03-26 and 2024-11-05. When a request’s Accept header lists text/event-stream, the response is sent as a single server-sent event.
Memory tools
remember saves one fact, decision or preference. recall searches saved memories by keyword. recall_as_of lists memories as they stood at a past time.
Storage & history
How data versions are kept, checked and protected.
Bitemporal versions
Each node and relationship version records valid time (when the fact was true) and recorded time (when the database stored it). An update closes the previous version and keeps it in history.
Hash-chained history log
Node and relationship writes committed through Cypher, /execute or import are appended to each graph’s history log, and each record carries the SHA-256 hash of the record before it. Schema, index and constraint changes and writes sent to the legacy POST /write route are not recorded. GET /graphs/{name}/audit/verify checks the chain and reports clean, or broken with the position of the break. The log is not signed or anchored outside the server, so the check does not detect a rewrite of the whole log, or removal of the most recent records, by someone with write access to the server.
Backup, restore and clone
An admin can back up a graph to an archive on the server, restore an archive into a new graph, and clone a graph into a new graph.
WORM mode
After POST /graphs/{name}/worm/engage, updates and deletes sent through Cypher, MCP tools, /execute and import are refused; creates are accepted. No API turns the mode off. It does not cover the legacy POST /write route on the default graph, schema or index changes, or an administrator dropping the whole graph. WORM mode is available under an Enterprise license; contact us.
Constraints
UNIQUE (one property or a combination), NOT NULL, NODE KEY and property type constraints and, from 1.4.2, required-relationship constraints, checked on every write; a violation applies nothing. See the Cypher reference.
Covering and spatial indexes
db.createCoveringIndex stores extra property values with an index key; on a label that already has rows, the call builds the index before it returns. db.createSpatialIndex indexes geographic or cartesian points for bounding-box and distance queries.
Bitemporal label index
db.buildBitemporalLabelIndex(label) builds an index that MATCH (n:Label) AT VALID reads use instead of scanning the label’s history. From 1.4.3.1 the build runs as a background job (db.bitemporalLabelIndexStatus()).
Cypher & search
Query language features, procedures and search.
AT VALID and AT RECORDED
MATCH … AT VALID <time> reads what was true at that time; AT RECORDED <time> reads what the database had stored at that time. An AT VALID prefix at the start of a write statement sets the valid-from time of its writes. HTTP Cypher requests and the MCP cypher_query tool accept atTime.
MERGE and DETACH DELETE
MERGE on a single key property is resolved atomically. Plain DELETE refuses a node that has relationships; DETACH DELETE removes the node and its relationships.
EXPLAIN
EXPLAIN returns a text plan without running the query. EXPLAIN ANALYZE, or PROFILE from 1.4.1, runs the query, including any writes, and adds per-part row counts and timings.
Procedure catalog
CALL db.listProcedures() returns each procedure’s name, signature, description and status. Call only procedures whose status is ready.
Bulk relabel
db.relabel(oldLabel, newLabel [, where]) moves nodes from one label to another, optionally filtered by property values, and returns the number of nodes relabeled and skipped.
Search
Keyword search with BM25 (gds.fulltextSearch), declared full-text indexes with phrases, Boolean and proximity connectors and snippets (db.fulltext.query, from 1.4.3; several fields with weights, filters before ranking, matched words, excerpts and grouping from 1.4.3.3), vector search (gds.vectorSearch; partial rescoring with rescore from 1.4.3.2), reciprocal-rank or score fusion of both (db.hybridSearch; weights and reads at a past time with filters from 1.4.3.3), and vector matches expanded through the graph (gds.semanticMatchExpand).
Belief revision
db.recordBelief records a belief with links to its evidence, db.reviseBelief creates a revised version, and db.beliefLineage returns the chain of versions.
Security & access
Credentials, tenancy and access rules.
API keys
Keys start with gq_ and are sent as bearer tokens. A key can be limited to a list of graphs and given an expiry. The key is shown once, when it is created.
Graph ownership
Graphs created from the dashboard belong to your tenant. An API key can be limited to a list of graphs.
Field access rules
The graph schema can declare, per property, which roles may read and write it. A read without a permitted role returns null or leaves the property out, and a search over the property is refused. Read rules apply to Cypher requests, the node and search routes, and the MCP tools that run Cypher or read records. Write rules are checked only when a Cypher CREATE creates a node; they are not checked on MERGE, SET, bulk import, document ingest (including the MCP save_document tool) or raw execute requests.
Access-rule audit log
Reads that access rules deny in Cypher, refused searches and refused node creates are logged in a hash-chained log. Redactions on the direct node and relationship routes and allowed reads are not logged. db.aclEvents reads the log and db.verifyAclChain checks it.
Capability tokens
An admin can issue a token bound to one graph and a list of scopes, valid for up to 300 seconds and optionally single-use.
Private-mode keys
With a key that has privacy mode set, only expressions built on count, sum or avg may be returned; noise is added and each aggregate spends from the key’s ε budget. This release does not give a differential-privacy guarantee; do not rely on private mode alone to protect individual records.
Tenant admin role
A tenant_admin key can create and drop graphs in its tenant’s namespace and list its tenant’s keys. From 1.4.2 it can also list and cancel the running statements of its tenant.
Access policy by label and relationship type
From 1.4.2 a graph can carry an access policy that says, per role, which labels and relationship types may be read and written. For a credential the policy restricts, hidden nodes and relationships are absent from Cypher reads, and refused writes answer 403 ACCESS_DENIED. The policy applies to requests that carry a credential; it does not decide the /admin routes. See Known limitations for what a restricted credential cannot use on this release.
MCP, API & SDKs
Ways to connect to InvariantDB.
MCP server
28 tools at https://invariantdb.com/graphs/{graph}/mcp, over JSON-RPC 2.0, authenticated with an API key or OAuth sign-in. See MCP server.
HTTP API
The OpenAPI specification is at /openapi.yaml. Most errors use a JSON envelope with error, code and message, plus subcode, position, details or retry_after_ms where they apply, and from 1.4.2 retryable and retry. From 1.4.2 write requests accept an Idempotency-Key header.
SDKs
Python and Node client libraries, version 0.9.0. From 0.9.0 they can send an idempotency key with each write and resend a keyed write after a lost answer. See SDK.
Query time limit
The X-Query-Timeout-Ms header sets a time limit for a Cypher request, up to 290,000 ms. Without it the server’s limit applies (240 seconds by default on a self-hosted server, 60 seconds on the hosted service). A query that exceeds its limit returns HTTP 408 with subcode QUERY_TIMEOUT.
Build identifier
Each response carries an x-invariantdb-build-id header that identifies the server build that answered.
Known limitations
Behavior in the current release to plan around.
Crypto-shred is in development
Crypto-shred (per-subject encryption keys and key destruction) is in development. On the current release, declaring an encrypted property is refused by default, and db.destroySubjectKey is refused without recording a receipt. Contact us before relying on it.
Temporal property filters scan history
CREATE TEMPORAL INDEX is accepted but does not speed up reads; it returns the warning TEMPORAL_INDEX_PENDING. A property filter combined with AT VALID or AT RECORDED scans the label’s history and returns correct results; HTTP responses carry the warning TEMPORAL_EQ_FULL_SCAN.
Writes that scan an unindexed label wait for each other
Write statements on one graph run one at a time, so a write whose MATCH reads a whole unindexed label holds up the writes behind it. Index the property the MATCH looks up.
Covering index created on a populated label
While db.createCoveringIndex builds the index on a label that already has rows, queries that use the index can leave out rows that existed before the call. Create covering indexes before loading the label, or in a quiet period.
As-of reads on graphs with a long history
An as-of read of a label whose as-of index is not usable yet (a label first written since the graph’s last full maintenance, until its background build has finished) reads the whole graph’s history and can be refused with TEMPORAL_READ_LIMIT; from 1.4.3.1 the refusal starts a build of the label and names the job, and a read pinned to an indexed key answers. A read without a label reads the whole graph’s history. db.labelCountsAsOf(t) reads the whole graph’s history and is refused on graphs whose history is longer than the limit; count one label at a time there with MATCH (n:L) AT RECORDED t RETURN count(n). A read at a valid time with LIMIT can be refused the same way on a graph that several writers wrote at the same time. The MCP tool cypher_query returns this refusal as -32603 without data.subcode.
Some requests run to their end after the connection closes
Statements stop when their connection closes on the Cypher and explain routes, the MCP tool cypher_query and, from 1.4.3, /sql, /graphql, /nlq and /investigate (both retired from 1.4.3.3) and the MCP tools that call a procedure. Other MCP tools, and writes sent with an idempotency key, run to their end.
First exact vector search after a flush
The first exact (non-HNSW) vector search of a label after a flush takes longer than the searches that follow.
range() is limited to 1,000,000 entries
A range() call that would produce more than 1,000,000 entries is refused before the list is built: from 1.4.2.2 with subcode BUDGET_EXCEEDED and a message that names the count asked for and the limit (before: ARGUMENT_TYPE_MISMATCH).
Mutation counts count operations, not changed records
stats.mutations counts the write operations a statement processed: a MERGE adds 1 per row whether it created or matched, and a schema statement adds 1. A MERGE that only matches writes nothing (stats.graph_seq does not move). To tell a create from a match, set a marker with ON CREATE SET.
Access policies: what a restricted credential cannot use
On 1.4.2 a credential whose reads an access policy restricts is refused, with 403 ACCESS_POLICY_SURFACE_UNAVAILABLE (MCP -32007) and nothing run: procedures other than db.access.* (including full-text, vector and hybrid search), temporal reads, EXPLAIN, index and constraint statements, the HTTP routes other than the Cypher, cursor and MCP routes, and the MCP tools that do not run Cypher, except get_node_by_str and get_edge_by_str. Rules are by label and relationship type; there are no rules on individual records. GET /graphs/{name}/schema/definition stays available to a restricted credential and lists the declared label and property names, including labels it cannot read. A refused uniqueness check or a refused DELETE can show that a hidden value or relationship exists, and a restricted credential can infer how many hidden nodes and writes a graph holds from the id of a node it creates and from the sequence numbers in a write’s answer (nothing hidden is named). A server run with authentication off does not apply the policy. From 1.4.2.5 the MCP tools answer these refusals with -32007 and the subcode.
Required-relationship constraints are checked on current records
The rule is not checked at past valid times, and history reads are not checked. A relationship written with a future AT VALID time counts from the moment it is written. A node that carries both labels can satisfy the rule with a relationship to itself. A statement too large to commit at once is checked part by part, and an import that lists all nodes before all relationships is refused for the constrained nodes.
Counts in memory recall are left to the reader
recall returns items for counting and listing questions and leaves the count to the reader. Server-computed figures (aggregates) are experimental and off by default.
Reads while a graph holds future-dated versions
From 1.4.2.4, while a label or relationship type holds a version whose valid time starts in the future, reads of it without a time run as of now and do not use the count and index fast paths: counts and index lookups on such a label take tens of milliseconds instead of a few milliseconds or less. Reads that may touch any label (a node pattern without a label, a relationship without a type, a procedure call) are affected on such a graph too. A graph that never received a future-dated write is not affected; the reads return to the fast paths once the latest such time has passed. Searches (recall, db.hybridSearch, db.fulltextSearch) read the label at that time instead of the search indexes.
Constraints check the latest recorded version
UNIQUE, NOT NULL, required-relationship and quote rules are checked against the latest recorded version of each node and relationship, while a read without a time answers what is valid now. When a write dated in the future changes what a constraint checks, a plain read can show, for example, a claim without its support until that date. Read with AT RECORDED '<now>' to see the recorded state the constraint checks.
Future-dated and backdated writes
A memory saved with a future validFrom is not returned by recall before its date; to keep an upcoming date findable now, write it into the text. MERGE … ON CREATE SET records two versions for one create (db.history shows the node without the ON CREATE values first) when an ON CREATE value reads the node itself or the graph, assigns to another variable, or the MERGE is of a path; from 1.4.3.1 other creates record one version. A delete without AT VALID cannot end an entity while a later-dated change is recorded for it; end it at that time with AT VALID. A backdated correction under a later-dated version is refused when it would lose values of that version; from 1.4.3.2 write it with FOR PORTION OF VALID FROM … TO …, which keeps the later versions. FOR PORTION OF does not delete (mark the interval with a property or a label instead), and its SET values must be literals or parameters, not values read from the graph.
Vector search narrowing has a fixed shape
A vector search is narrowed by a pattern only for one MATCH of one path (1 to 4 relationships, each with a type and one hop), without AT VALID / AT RECORDED, with comparisons in WHERE and ORDER BY vector.similarity(…) DESC LIMIT k. Other statements walk the whole pattern and give the same rows. Pass the nodes to gds.vectorSearch as nodeIds instead. ORDER BY score DESC sorts a missing similarity first; write NULLS LAST.
Constraint refusals name the storage id
A required-relationship refusal names the node by its storage string id (details.node_id), which Cypher generates for nodes it creates, so in a statement that creates several claims the refusal does not say which one failed. Create one claim per statement when you need to know. OPTIONS { quote: true } does not store the quote hash on relationships that already exist; each gets it when next written.
Searches during large vector writes
From 1.4.3.2 a label’s vector file is brought up to date in the background during bulk writes. While the background pass takes in a file’s recent changes, a search of that label can wait up to 30 seconds and then answer 409 VECTOR_INDEX_BUILD_IN_PROGRESS (nothing ran); call it again once the background pass has ended, usually within seconds. This is most likely right after a large update of the file. During a bulk load of about a million vectors the file is rewritten several times, and the first search after a large rewrite takes longer than the ones after it. Searches stay exact.
Row order without ORDER BY
With the planner on by default from 1.4.3.2, rows of a statement without ORDER BY can come in a different order than on 1.4.3.1, and a LIMIT without ORDER BY can keep different rows. Add ORDER BY on a unique key wherever the order or the kept rows matter.
Multi-statement transactions are not on the hosted service
On the hosted service the transaction routes answer 404 TRANSACTIONS_DISABLED. Write what must change together in one statement, which is applied all or nothing.
Full-text queries over several fields or grouped rank every match
From 1.4.3.3, a db.fulltext.query over several fields or with group_by ranks every match before it returns a page, so its cost grows with the number of matches, not with k, and it is not paged by cursor. On large labels narrow it with a filter or rarer words.
Undirected walks at a valid-time cut are slow
An undirected relationship walk between two labels at an AT VALID cut over a large graph (MATCH (a:A)-[r]-(b:B) AT VALID t RETURN count(r)) is slow and can reach the statement time limit. Directed or typed walks at a cut are much faster; write the direction or the type where you can.
Column files serve some statements only
From 1.4.3.3, declared column files are read by statements the planned query engine runs; other statements read the records, with the same answers. Reads at a past time never use them.
The MCP forget tool is not available
Each call returns JSON-RPC error -32006.
For the full feature list, see Features. For operations, see Operations and Cluster.