Skip to content

Refuse property values that some store refuses or alters where an episode enters - #1792

Draft
edwinyyyu wants to merge 2 commits into
MemMachine:mainfrom
edwinyyyu:fix/property-value-domain-main
Draft

edwinyyyu wants to merge 2 commits into
MemMachine:mainfrom
edwinyyyu:fix/property-value-domain-main

Conversation

@edwinyyyu

@edwinyyyu edwinyyyu commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Purpose of the change

Summary

An episode's data fans out to the episode store, semantic memory, and episodic memory's segment store and vector store at once. Each value that becomes a property value (a metadata key or value, the producer, the recipient, the role, or the timestamp) was accepted as any string, number, or datetime, so a value that one store refuses was written by the others first, and the request answered 500 with the episode in some stores and not in others. A value that one store alters was kept as given in the others.

This change refuses those values where an episode enters, before any store is called:

  • POST /api/v2/memories answers 422.
  • MemMachine.add_episodes callers get a ValidationError when they build the EpisodeEntry.

The property value domain is defined once, in memmachine_common.api.spec:

  • booleans;
  • signed 64-bit integers;
  • finite floats;
  • strings free of U+0000 and surrogate code points;
  • datetimes whose UTC offset is a whole number of minutes and whose UTC instant falls in years 1 through 9999.

validate_property_value checks any property value, PropertyStr types a string, and PropertyDatetime types a datetime. MemoryMessage types its metadata keys and values, producer, produced_for, and role as PropertyStr and its timestamp as PropertyDatetime. EpisodeEntry types producer_id, producer_role, produced_for_id, and its metadata keys as PropertyStr, and checks created_at and its metadata values that are property values. Metadata values that are lists, objects, or null are kept as they are.

What each store does with these values on main (measured; full matrix in the issues):

value refused by altered or lost by
U+0000 in a string PostgreSQL episode store, segment store, and semantic storage none; Milvus Lite cannot filter on it
lone surrogate in a string PostgreSQL stores, Qdrant, Milvus, semantic storage, SQLite episode store columns none; SQLite vector stores cannot filter on it
NaN or an infinity (in-process only) PostgreSQL episode and segment stores, Qdrant gRPC lost by Qdrant REST; Milvus records become unreadable
integer outside signed 64 bits (in-process only) Qdrant gRPC, Milvus (except 2^63) turned into a float by Qdrant REST (except 2^63, which it cannot filter on); SQLite vector store filters on it raise
datetime whose UTC instant leaves years 1 through 9999 every store but Qdrant none
datetime whose UTC offset has seconds or a fraction of a second none Qdrant cuts the offset to whole minutes and so moves the instant; the other stores drop a fraction of a second from the offset

The domain is checked where an episode enters, not on the shared PropertyValue alias. The stores' own models (Record, Event, Segment, and Episode) carry that alias, so a check on it would:

  • preempt each store's own checks on its input;
  • run again for every record read back: Record validation with 12 properties went from 5.6 µs to 9.7 µs in a local measurement;
  • fail semantic memory's vector records after their SQL rows are written.

The alias stays as it was.

Fixes #1793. Fixes #1794. Fixes #1795. Part of the fix for #1784, whose store-side refusal is in #1788.

Not covered here, each with its own issue:

Commits

  1. Refuse property values outside the domain when an episode entry is built
  2. Refuse add-memories property values outside the domain with 422

Stack

21 open PRs: three independent PRs, and the vector store tree of short parallel branches. Every PR in the tree has feat/horizontal-scaling as its GitHub base, and the independent PRs have main. The branches are in a fork, and a pull request can target only this repository's branches, so the on column gives the order the PRs build on each other. A stacked PR's diff on GitHub includes the PRs under it until they merge.

Independent of the vector store tree, directly on main:

# PR change on
— #1624 Make no memory request create a project main
— #1786 Refuse a filter value of the wrong type for a datetime column, and answer an invalid list filter with 422 (port of #1620) main
— #1792 (this PR) Refuse property values that some store refuses or alters where an episode enters main

The vector store tree. Each PR builds on the one in its on column; PRs on the same parent are parallel branches and do not depend on each other. #1631 is closed, superseded by #1733–#1736, which hold its changes split in four, with review changes since. #1702 and #1670 sit beneath #1627, whose code depends on them. Until the PRs under it merge, their changes show in a stacked PR's diff.

# PR change on
[vector store scale-out 1/6] #1671 (merged) Remove custom sharding from the Qdrant store (port of #1654) main
[vector store scale-out 2/6] #1733 (merged into feat/horizontal-scaling) Answer vector store queries with record UUIDs and scores, and refuse invalid inputs feat/horizontal-scaling
[vector store scale-out 3/6] #1734 (merged into feat/horizontal-scaling) Arbitrate vector store collections in a SQL registry, with an incarnation per collection life and a purge feat/horizontal-scaling
[vector store scale-out 4/6] #1735 (merged into feat/horizontal-scaling) Move the Qdrant store onto the collection registry feat/horizontal-scaling
[vector store scale-out 5/6] #1736 (merged into feat/horizontal-scaling) Move the Milvus store onto the collection registry, against a Milvus server feat/horizontal-scaling
— #1775 (merged into feat/horizontal-scaling) Accept attempts exhausted in the lifecycle churn contract, and say which Qdrant operations filter on the incarnation feat/horizontal-scaling
— #1779 (merged into feat/horizontal-scaling) Classify Qdrant errors by status code alone feat/horizontal-scaling
— #1813 (merged into feat/horizontal-scaling) Create Qdrant collections with strict mode off feat/horizontal-scaling
— #1788 Refuse a repeated record UUID or a non-finite property value at upsert, and state the datetime property contract feat/horizontal-scaling
— #1631 (closed) Superseded by #1733–#1736, which hold its changes split in four, with review changes since —
[user properties 1/2] #1702 Keep undeclared properties out of the vector store feat/horizontal-scaling
[user properties 2/2] #1670 Remove per-project filterable properties (port of #1606) #1702
[vector store scale-out 6/6] #1627 Make a vector store one collection, with string-keyed partitions #1670
[session storage 1/2] #1622 Create a session's storage with the session, never on a request #1627
[session storage 2/2] #1625 Remove open-or-create from both stores, and close from the segment store #1622
[declared schema 1/2] #1628 Make a vector store filter only on the properties it declares #1627
[search results] #1663 Score every vector search by cosine similarity, and name scores for it (port of #1598's cosine half) #1628
[declared schema 2/2] #1616 Close the filter union, and make negation the complement on every backend #1663
[sqlite store fixes 1/7] #1460 Publish vector index files atomically (but not durably) #1663
[sqlite store fixes 2/7] #1469 Never reuse a row id in SQLiteVectorStore #1460
[sqlite store fixes 3/7] #1672 Own the search engine's concurrency in the store, not in each engine (port of #1612) #1469
[sqlite store fixes 4/7] #1673 Serialize a partition's writes so the engine sees them in order (port of #1607) #1672
[sqlite store fixes 5/7] #1674 Refuse a pending row replay cannot honor, instead of dropping it (port of #1608) #1673
[sqlite store fixes 6/7] #1675 Take SQLite's write lock at BEGIN, not at the first write (port of #1609) #1674
[sqlite store fixes 7/7] #1676 Give every write a fresh row id, so a key names one version (port of #1610) #1675
[qdrant options] #1618 Let a deployment tune a Qdrant collection's HNSW, optimizers and quantization #1663
[milvus options] #1741 Let a deployment tune a Milvus collection's vector index and its searches #1618

This PR is its 2 commits, ab419b16f, 95ded7618, directly on main, independent of the vector store tree: review and merge it in any order.

Verification

  • Store-level measurement on main at ad8ff24, one value at a time through each store's own code, with a throwaway probe that is not committed:

    • episode store on SQLite and PostgreSQL;
    • semantic memory set registration through SemanticSessionManager.add_message on pgvector storage and vector-store storage, on SQLite and PostgreSQL;
    • LongTermMemory on the event backend into the SQLAlchemy segment store on SQLite and PostgreSQL;
    • LongTermMemory into SQLiteVectorStore, SQLiteVecVectorStore, Qdrant 1.17.0 over REST and gRPC and in local mode, and Milvus 2.6.24 and Milvus Lite, with and without a declared user property schema.
  • POST /api/v2/memories end to end through MemMachine.add_episodes, with the real episode store, segment store, and semantic storage on SQLite and on PostgreSQL and SQLiteVectorStore, counting rows in each store after every request:

    request before after
    metadata value with U+0000 SQLite: 200, stored everywhere; PostgreSQL: 500, only semantic sets written 422, nothing written
    metadata value with a lone surrogate SQLite: 200; PostgreSQL: 500, only semantic sets written 422, nothing written
    producer with U+0000 SQLite: 200; PostgreSQL: 500 422, nothing written
    timestamp 0001-01-01T00:00:00+05:00 500 422, nothing written
    timestamp 2026-01-01T12:00:00+05:30:45 200 422, nothing written
    metadata {"k": "v"} 200 200, stored everywhere
  • uv run --frozen --all-extras ruff check and ruff format --check: clean.

  • ty check: packages/server with Python 3.12 and 3.14, and packages/common and packages/client with Python 3.10 and 3.14: clean.

  • uv lock --check: clean.

  • Unit suite (pytest packages): 2405 passed, 3 skipped.

  • Integration suite (-m "integration and not slow" over the four CI paths), with PostgreSQL, Neo4j, and Qdrant 1.17.0 containers: 1454 passed, 188 skipped (tests that need an OpenAI or AWS key).

  • Each test fails under the mutations named in its commit message, which were each applied and run.

  • git merge-tree --write-tree upstream/feat/horizontal-scaling HEAD: no conflicts.

🤖 Generated with Claude Code

edwinyyyu and others added 2 commits October 7, 2026 12:52
An episode's producer, role, recipient, creation time, and scalar
metadata values become property values in every memory it reaches, and
EpisodeEntry accepted any string, integer, float, or datetime there. So
MemMachine.add_episodes wrote an entry to the stores that keep a value
and failed in the stores that refuse it. Measured on main through each
store's own code: NaN and infinities are refused by PostgreSQL JSONB
and Qdrant gRPC, lost by Qdrant REST, and leave Milvus records
unreadable; integers outside signed 64 bits are refused by Qdrant gRPC
and mostly by Milvus, become floats in Qdrant REST, and make filters on
them raise in both SQLite vector stores; U+0000 is refused by
PostgreSQL; lone surrogates are refused by PostgreSQL, Milvus, and
Qdrant; a datetime whose UTC instant falls outside years 1 through 9999
is refused by every store but Qdrant; and Qdrant shifts the instant of
a datetime whose UTC offset carries seconds.

Define the property value domain once, in memmachine_common:
validate_property_value for any property value, and PropertyStr for a
string. Type EpisodeEntry's producer, role, recipient, and metadata keys
as PropertyStr, check its creation time and its metadata values that
are property values with validate_property_value, and keep metadata
values that are lists, objects, or null as they are. The check sits
where an entry enters rather than on the shared PropertyValue alias,
which the stores' own models carry: there it would preempt each store's
own checks on its input, run again for every record read back, and fail
semantic memory's vector records after their SQL rows are written.

Tests fail under these mutations:
- No int64 range check: the int64 refusal tests.
- int64 bounds narrowed by one: the int64 boundary acceptance tests.
- No finiteness check: the NaN and infinity tests.
- Pattern without the surrogate range: the lone surrogate tests.
- Pattern without U+0000: the NUL tests.
- No whole-minute offset check: the offset seconds and microseconds
  tests.
- No UTC range check: the year 1 and year 9999 tests.
- Every offset refused: the whole-minute offset acceptance test.
- Metadata without the value check: the metadata value tests.
- Metadata keys typed str: the metadata key test.
- producer_id, producer_role, or produced_for_id typed str: that
  field's test.
- created_at without the domain check: the created_at tests.
- Metadata narrowed to scalar values: the structured metadata test.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Every MemoryMessage field that becomes a property value accepted any
string or datetime, so a value some store refuses passed the request
and failed in that store after the others had written it. Measured on
main through POST /api/v2/memories with the real stores: a metadata
value with U+0000 or a lone surrogate answers 500 after semantic memory
has recorded the episode in its sets, on PostgreSQL; a producer with
U+0000 is refused by the PostgreSQL episode store, segment store, and
semantic storage while SQLite and the vector stores keep it; a
timestamp of 0001-01-01T00:00:00+05:00 answers 500; and one with an
offset of +05:30:45 is stored, where Qdrant shifts its instant.

Type the metadata keys and values, producer, produced_for, and role as
PropertyStr, and the timestamp as the new PropertyDatetime, so the
request answers 422 before any store is called. With the same requests
after this change, every one answers 422 and the episode store, segment
store, vector store, and semantic history gain no row, on SQLite and
PostgreSQL.

Tests fail under these mutations:
- metadata typed dict[str, str]: the metadata value and key tests.
- Metadata keys typed str: the metadata key tests.
- producer, produced_for, or role typed str: that field's test.
- timestamp typed datetime: the timestamp tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@edwinyyyu
edwinyyyu marked this pull request as draft October 7, 2026 20:48
@edwinyyyu
edwinyyyu marked this pull request as draft October 7, 2026 20:48
This was referenced Oct 7, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment