Design: MemMachine server redesign (tenants, event memory API, configuration, lifecycle, schema management) - #1579
Conversation
…laims purge_partition existed to serve a promise the delete path can no longer make: prompt physical erasure is not something the store can keep on every dialect (on SQLite any writer past the busy timeout fails), and the inline drain that used it, first of the global queue and then scoped to the deleted key, added a second purger, a polling loop while an entry was held, and a slow-purge warning, all to shorten a window that no contract requires to be short. drop_session_partition now deletes the collection and the partition, nulls its handles, and returns; the partition is unreachable at once and its rows are reclaimed by the resource manager's sweeper within its interval. The ABC keeps one purge method, the sweeper, whose contract already says a deployment must run it. The composite queue index, the held-entry pause, the existence read, the slow-purge warning and their tests go with the method; the sweeper's same-tick ordering wording and the queue-row docstring's forensic key stay. A per-tenant reclaim step returns with the tenant lifecycle layer (MemMachine#1579), where single-use keys make it job-like: progress, retry and failure per tenant, which the global sweeper cannot attribute. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
2d315ac to
7e33dc2
Compare
…laims purge_partition existed to serve a promise the delete path can no longer make: prompt physical erasure is not something the store can keep on every dialect (on SQLite any writer past the busy timeout fails), and the inline drain that used it, first of the global queue and then scoped to the deleted key, added a second purger, a polling loop while an entry was held, and a slow-purge warning, all to shorten a window that no contract requires to be short. drop_session_partition now deletes the collection and the partition, nulls its handles, and returns; the partition is unreachable at once and its rows are reclaimed by the resource manager's sweeper within its interval. The ABC keeps one purge method, the sweeper, whose contract already says a deployment must run it. The composite queue index, the held-entry pause, the existence read, the slow-purge warning and their tests go with the method; the sweeper's same-tick ordering wording and the queue-row docstring's forensic key stay. A per-tenant reclaim step returns with the tenant lifecycle layer (MemMachine#1579), where single-use keys make it job-like: progress, retry and failure per tenant, which the global sweeper cannot attribute. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Design proposal for tenant lifecycle management above the stores: a tenant record with step rows and a reconciler, UUID-only store keys minted per tenant lifetime, a single tenant handle, and the resource contracts the segment and vector stores expose to that layer. Rebuilt on speedkick, which carries the segment store overhaul (MemMachine#1548) the document refers to. Tracking: MemMachine#1574. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
7e33dc2 to
32c813f
Compare
The lifecycle design cannot be fixed inside the current API, configuration and wiring, so the document now covers the server: tenants (name + UUID id, renamable, name in one SQL table, UUID keys in every store), a control plane (tenant service, job table, reconciler) separate from the data plane (subsystems serving data operations by tenant id in the path, no routing handle), the subsystem registration contract (schema with mutable/immutable options, provision and delete hooks), event memory with the segment store as system of record and no episode store, store contracts including a SQL ledger for vector collections with a derived reclaim grace period, a declarative configuration document with providers and tenant templates, eager startup by constructor injection, the v1 HTTP API with one error handler, and schema management (Alembic per component, boot modes, container provisioning). Short-term, semantic and declarative memory are not wired in. File renamed from tenant_lifecycle.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
… config Every store now rejects operations on a deleted key by itself, with its registry row in SQL: writes pin it FOR SHARE, the logical delete takes it FOR UPDATE and so waits out in-flight writes, reads verify it. The vector store adopts this through a SQL ledger, and dead keys stay as tombstones swept at a bounded rate, so no clock is compared anywhere and no key is forgotten while a record could exist. The segment store no longer fences on behalf of the subsystem. Subsystems own their per-tenant state; only the tenant service reads the tenant table. The reconciler is a role a deployment runs in as many or as few processes as it needs. An event store returns as the system of record, designed new, so derived data can be rebuilt. Configuration is the components' own parameter models with typed references resolved by the loader; nothing receives a catalog. Schema is upgraded only by an operator's command and verified at startup. The SQLite vector stores keep per-collection tables, as their docstring's reason for avoiding vec0 partition keys stands. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
The SQLite stores are not for large deployments; they are fine as long as they work and obey the contracts. State the rule as one about cost at scale in the requirements, the schema principle, and both places that mention the SQLite stores' per-collection tables. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
State the rule as two halves: component schema runs only in the setup command, which serves nothing and cannot race, and serving and reconciler processes verify it; tenant-specific DDL is the only DDL allowed elsewhere, avoided where it would be expensive at scale. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
…name to episodic memory Stores fence on the caller's UUID alone: the registry row keyed by it is the whole fence, and the segment store's incarnation goes; replacement is the subsystem's, by minting a new key per generation and recording it in its own per-tenant row. The fence section now defines the write step as a FOR SHARE row lock released by the database at transaction end, with the two settings that bound a live session holding it. The event store is its own component, the system of record, with positions that subsystems process by; EventMemory is renamed episodic memory, so "event" is the caller's ingestion type. The uuid5-derived segment and derivative ids are withdrawn; idempotency is per event, by forgetting an event's derived rows before reprocessing. A table surveys Qdrant, Milvus, pgvector, Pinecone, S3 Vectors, Weaviate and Chroma at the tier that scales, for per-tenant objects, rejection, listing and reclaim. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
A full rebuild costs what an ingestion costs, so it is one, into a new tenant. The rebuild job, its endpoint, and the per-generation keys it needed go; the tenant id is the key in every store again, and the subsystem's per-tenant row holds only the watermark and the applied configuration. The event store keeps its two uses: repair of partial processing and processing history for a subsystem enabled later. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Chroma's own multi-tenancy write-up warns that metadata filtering slows as users and documents grow, so its row now reads collection per tenant. Its per-tenant object is a Collection handle: get_collection is one round trip resolving the name to the collection's UUID (verified in chromadb/api/fastapi.py), after which every data operation addresses the UUID. The paragraph under the table says what that costs, that the instance cache pays it once per open, and that storing the UUID in the ledger removes the call. Per-collection cost and Chroma Cloud's collection count are marked unverified. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Every store operation takes the key. The registry row that fences it also holds what addresses the tenant on the backend (codec configuration, container, collection UUID), so nothing is opened or closed per tenant, a process holds no per-tenant state, and a configuration update takes effect on the next request. Chroma's collection UUID is recorded in the ledger at creation and operations go to its HTTP API by that UUID; Weaviate's wrapper is built per call. The per-tenant instance cache, its TTL, and MemMachine#1548's partition handle go. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Replace the "verified before an implementation" note for S3 Vectors with the documented figures: 10,000 indexes per bucket, top-K up to 10,000 per query, DeleteVectors by key at 500 per call and no delete by filter, filterable metadata 2 KB per vector, filters evaluated during the search, numeric-only range comparisons. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Adopt MemMachine#1531's ConcurrencyScope: every component computes its scope from its params, a composition's scope is the minimum of its parts', the deployment declares the scope it runs at, and startup refuses any component narrower than that. The horizontal scaling requirement is stated at cluster scope; the SQLite stores declare process or machine and are held to every contract within it. Scope declarations are tabulated, the file lock is named as what gives SQLite-backed stores machine scope, and "large deployment" wording is replaced by scope. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
…as reference Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
From the registry session's review of the draft. Store creates are strict and raise on any row under the key; idempotency is the component's ensure, which knows the key's provenance, and a row in a non-live state is a reused key that raises. A table states what every operation does with a non-live key. Tombstones are kept by default; pruning is an operator's trade gated on a clean sweep, with what it gives up stated. The fence's cost is stated for sizing: a pooled connection held across each remote write, one row read per query. Containers are retired by the schema command once undeclared and unreferenced. maintain runs without exclusion and says why that is safe. MemMachine#1530 is recorded as agreeing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Where a backend's tenant is a native object (a Chroma collection, a Weaviate tenant, a SQLite table), create is two steps, so the ledger gains a creating state that ensure resumes, and the row carries the object's address. The Chroma row and the paragraph under the table take the registry session's chromadb 1.5.9 findings from the MemMachine#1579 comment: a stale UUID raises NotFoundError, so the UUID is the fence; duplicate create is rejected but untyped, so already-exists is told by message; concurrent creates yield one winner; metadata values are scalars. The per-collection cost and Cloud collection counts stay marked unverified. schema status and prune's dry run report prunable versus awaiting tombstones. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Same signature and reason; MemMachine#1530's names are reused and separated by incarnations, so its create raising means exists, while here a row under a never-reused key is a violated invariant and kept tombstones are load-bearing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
…d-poll, constructor-derived configuration Tombstones move to the tenant service: a deleted tenant's row stays as the one detector of a reused id, refusing a duplicate at mint before any store is touched, and drives a periodic re-sweep through every component's reclaim, so no store keeps tombstones and the stores cannot disagree about a reused key. The reconciler's lease becomes a held row lock, and a paragraph says why locks rather than leases everywhere. Creation, deletion and configuration updates respond 202 and are polled or waited on; no lifecycle request fails for a job the reconciler will retry; the get-or-create flag goes, the 409 carrying the existing tenant. Configuration updates keep the tenant active and reach every process through the component's per-tenant row; an option is immutable exactly when changing it would touch existing data. Configuration is the resources' constructor arguments: plain classes with typed constructors, one flat resources map with unique ids and a kind table as the only registration point, dependencies reflected from annotations, third-party clients as factory kinds; the relation to the earlier resource_initializer proposal is stated. Concurrency scope levels are process, host, cluster. Both SQLite vector stores move to shared tables, and no store creates a table per tenant. All numeric defaults are removed; settings are named. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
…llables A duplicate-name create is 409 tenant_exists and nothing else; a caller wanting the existing tenant looks it up. The sqlite-vec paragraph records that a vec0 partition key prunes the KNN to the tenant's own chunks (two orders of magnitude on 0.1.9 with 400 tenants) and that the per-partition cost is a chunk allocation both layouts paid identically, with chunk_size as the knob. A kind names a callable, class or factory, ours or third-party; Params models are optional grouping; the loader reflects the signature, validates scalars, and instance-checks resolved dependencies against annotations via validate_call, which is stated as exactly what a runtime check can and cannot prove. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
System fields are first-class typed parameters (since, before, producers) stored under a reserved memmachine_ key namespace so stores filter them with the same machinery as user properties. User properties are scalar, bounded, immutable, copied verbatim to derived data; an opaque payload is proposed for unfilterable metadata. Filter indexes are declared once per vector store in configuration and never created dynamically; undeclared keys are filtered in the segment store. A filter is a constructed closed-union tree, a JSON object under a generated schema at the API and in MCP, never a string language. Routing is per backend capability and selectivity: declared keys during the search, undeclared keys by a selectivity probe choosing an allowlist or bounded post-filter. Every count is a maximum; nothing is called top k. Reference: the default branch of edwinyyyu/MemMachine. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
The method transforms events into their derived representation, which EventMemory already called encode_events; process was overloaded. replay applies encode to added entries and forget to deleted ones. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
…budget reclaim becomes purge throughout, matching the shipped purge_deleted_partitions and the storage-engine sense of purging delete-marked rows. A sweep is the run that calls purge repeatedly; a delete job's step sweeps one tenant, the tombstone pass sweeps deleted tenants, both within reconciler.step_duration. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
The delete job calls the component's delete once and the tenant is deleted within seconds; a sweep job per tenant and component then calls purge batch by batch until nothing remains or its time budget is spent, and the tombstone pass resets sweeps so late garbage is purged too. Four job kinds: provision, delete, sweep, replay. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
… arguments Settings are deployment values, tenant configuration is per tenant with options as its fields, templates and overrides and defaults are named as such, a store records a partition configuration per key, requests carry request parameters, a job row carries arguments, and config, params, args and payload are not identifiers in new code. The stale enqueue bullet in the reconciler section becomes the reset bullet. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Python's conventions: Settings for values read from the environment and files, as pydantic_settings names them; Config for tenant and per-key configuration, as logging.config, configparser and Pydantic's ConfigDict do. The prose keeps the word configuration. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
The entity responsible for an event's content is its author, {id,
name}: the id a bounded string the application owns and filters by,
the name display data recorded as it was at the event and never
filtered, so no id-to-name directory is needed. It is an optional
field beside timestamp, so filtering is uniform over every event; the
Context union goes with it, and produced_for and the producer roles
are not carried over. Segments carry the author's id and name in
place of the context column.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Identity and rendering are separate channels. source_id is a system field beside timestamp, a bounded application-owned string, indexed and filtered by source_ids, uniform over every event. Context stays, with rendering as its only job: an extensible typed union, codec-encoded, never filtered, copied to segments, whose first member is AuthorContext with the readable name as it was at the event; a caller that knows a source's current name passes FormatOptions.names, and show_source_id prints the id beside the name, so no server-side directory exists and deadnaming is the caller's to correct at render time. produced_for and the producer roles are not carried over. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
…ptions Every hit returns source_id and context as data; an application that knows current names renders them itself. The server's text rendering prints what was recorded, and FormatOptions stays dates, times, locale and timezone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
A context is a mapping from part kind to one Pydantic part registered under its kind, so parts compose by merging and a step reads the one it needs by kind; no order, no nesting, no edit to a core union to add one, which is what MemMachine#1436's CompositeContext needed. First kinds: author (the readable name) and time_ranges (the temporal signal of MemMachine#1436, read by scoring). No context is the empty mapping; a source with no good name has a source_id and no author part, and names are a part's field, so source and name need not be bijective. A part is never filtered on. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
A context is a mapping from part kind to one registered Pydantic part, never None; the empty mapping is no context and the answer to a source with no name. The spec lists what was wanted of Context and answers each: registration through a kind table and an entry-point group, composition by merging with no order or nesting, propagation to segments and derive-time derivatives, rendering by per-kind contribution with placement the renderer's, never filtered, codec round-trip with unknown kinds preserved, additive evolution, API validation against a generated schema, and what MemMachine#1436's types become. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
…elds A session id is the stream an event belongs to; the one total order that expansion and context windows walk is within a session, so a tenant holding many interleaved conversations never expands a hit into another conversation. The criterion for a system field is stated: the server gives it semantics beyond filtering (timestamp orders, bounds and scores; session_id bounds the order; source_id is kept for universality), and everything else efficient is a deployment-declared indexed property, which closes the system set without starving it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
- blocks.md: block kinds registered like context parts (memmachine.block_kinds), `kind` as the discriminator, a render hook, UnknownBlock on decode, an unhandled-kind clause for segmenter and deriver. The kind is a system field of the segment, never of the event: `block_kinds` on search and expansion, `memmachine_block_kind` on vector records, `block_kind` column on segments. - Properties and filtering: state why Event and Context are reshaped (to prohibit dynamic index creation, which makes the deployment too difficult to maintain and its performance unpredictable); rule on channel, workspace, user, kind, importance and language against the criterion. - Segment store reads take the system filters as typed parameters (get_segment_contexts: source_ids, block_kinds; find_segments: all). - Event store types: session_id was missing from the Types paragraph. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
…o key Restores handles to the segment and vector stores as stateless bindings of a store's data operations to one key (and container): built without I/O, fenced per operation by the store, nothing to open, close, evict or go stale. `SegmentPartition` and `VectorCollection` are the two, one final class per store beside the key-taking data ABC. `EpisodicMemory` takes them as dependencies and its operations take no key, so past construction nothing can route to another tenant; the manager builds it per request and caches segmenters and derivers by options instead of `EpisodicMemory` objects by configuration. `for_container` is subsumed by `collection(key, container)`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
The two ABCs per store are the store (lifecycle, and constructing handles; the only place a key is named) and the handle (`EventPartition`, `SegmentPartition`, `VectorCollection`), the data surface bound to one key at construction with no method taking a key. The key-taking data ABC and the delegating final class are removed, so a wrong key is unrepresentable past construction instead of checked per call. Each backend implements both, as the current code does with `SegmentStorePartition` and `VectorStoreCollection`, minus open, close and stale state. Applied to the event store as well for uniformity. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
|
Four systematic problems account for most of the list. The design describes two registry models, a three-state key registry and the SQL stores' row-plus-purge-queue, in the vocabulary of the first, so several general statements are unimplementable for the event and segment stores. The reconciler's locking is stated three incompatible ways, and the design's own "no lock across remote I/O" rule is broken by the reconciler itself. The garbage story has open ends the design's own requirement forbids. And the main document was not re-swept after the handle and replay reshapes, so its store sections, wiring and API tables lag the component files. Design-level: decisions needed
Mechanical: contradictions and stale text
|
|
Reducing scope. |

Summary
A design proposal, no code:
design/server_redesign.md. It started as a tenant lifecycle design and now covers the server, because the lifecycle cannot be fixed inside the current API, configuration and wiring. Againstspeedkick, which carries the segment store overhaul it refers to (#1548, the merged copy of #1545). Tracking: #1574. Line references are tospeedkickat 7752e4c. Settings are named, never given numeric defaults. The premise since the last revision: the first deployment of this design carries no data forward.Decisions the document records
source_idbesidetimestamp(a bounded application-owned identity, indexed and filtered bysource_ids) and aContextthat is a mapping from part kind to one registered Pydantic part (composed by merging, read by kind, never filtered; first kindsauthorwith the name as recorded andtime_rangesfrom Add temporal scoring #1436; library users register kinds; replaces Add temporal scoring #1436's orderedCompositeContext; every hit returnssource_idand context as data, so an application renders current names itself and the server keeps no directory);producer,produced_forand roles are gone. Blocks are a registered family of kinds like context parts (textbuilt in, akinddiscriminator, arenderhook, unknown kinds survive decoding, and a segmenter or deriver with no policy for a kind passes it through as one segment with no derivatives); a segment is one block, so a block's kind is a system field of the segment and never of the event.EventandContextare reshaped for one reason: to prohibit dynamic index creation, which makes a deployment too difficult to maintain and its performance unpredictable; every kind of data gets a place that is not an index. An event store, its own component and the system of record, keeps them and a per-tenant log of additions and deletions. Ingest and deletion respond 202 once durable; processing is areplayjob per tenant and subsystem, one consumer each and so in commit order, executed by reconcilers or inline, with?wait=polling the watermarks.EventMemoryis renamedEpisodicMemory; its segment and vector stores are derived. No rebuild of derived data: a full reprocessing is a new ingestion into a new tenant.provision(idempotent by provenance),delete(logical unlink),purge(one bounded batch of physical removal,DONEorMORE; a sweep calls it until done or a time budget is spent). The tombstone sweep is the only scheduled duty. Create, delete and configuration updates respond 202 and are polled or waited on (?wait=); a duplicate name is 409tenant_existsand nothing else; configuration updates keep the tenant active, an option being immutable exactly when changing it would touch existing data. Data operations carry the tenant id in the path; there are no handles at the API. Inside the server a consumer of a store's data holds a stateless handle (SegmentPartition,VectorCollection): the store's operations bound to one key and container, built without I/O, fenced per operation by the store, nothing in it to go stale or evict; past its construction nothing can route to another tenant. The incarnation-bound, opened-and-closed handles of the current stores are not these.provision, which knows the key's provenance (agrees with Feat: Remove open-or-create semantics from VectorStore and SegmentStore (collection registry stack 3/5) #1530).process<host<cluster; Feat: Declare concurrency scope and data-plane contracts on storage ABCs (collection registry stack 4/5) #1531 is the reference): every resource computes its scope from its constructor arguments, the deployment declares the scope it runs at, and startup refuses anything narrower. The reconciler is a role.kindin settings (a table per slot family, entry points for out-of-tree kinds, optional extras imported lazily, one image); composition is one Python function, type-checked, with the standard composition shipped and another loadable by module path; settings are data (per-kind Pydantic models from environment and an optional file, schema and example generated, no wiring expressible); scoped views wherever a shared resource serves several holders; and three scopes told apart by identity: resources (built by the composition, with lifecycle), configured objects (built by a factory from resources and a tenant's structural configuration, interchangeable, cached by configuration), call arguments. Factories are resources and their products are not.EpisodicMemoryManageris the resource; it builds oneEpisodicMemoryper request in one constructor call from the tenant's two handles, its embedder and cached segmenter and deriver objects, so past that call nothing can name another tenant or another embedder's container, and does dispatch, defaults and validation only.EpisodicMemoryoperations name no key. Per-request options (reranker, limits) are call arguments with tenant defaults, and the separation is enforced by signature.since,before,session_ids,source_ids,block_kinds; a system field is one the server gives semantics beyond filtering:session_idbounds the order expansion walks, a block's kind selects its processing; the other candidates are ruled on in the text: a channel is a session, a workspace a set of sessions managed above the server, a user any of source, session, workspace or tenant, importance and language user properties; anything else efficient is a deployment-declared indexed property) are first-class typed parameters stored under a reservedmemmachine_key namespace, so stores filter them with the same machinery as user properties. User properties are scalar, bounded, immutable, copied verbatim to derived data; filter indexes are declared once per vector store, never dynamically; undeclared keys are filtered in the segment store. A filter is a constructed closed-union tree, a JSON object under a generated schema at the API and in MCP, no string language. Routing by backend capability and selectivity: each store declares the filter nodes it evaluates during a search (a table covers the major providers; Chroma lacks$ne,$notand$exists, sqlite-vec takes comparisons joined byANDonly), and any other predicate is routed to the segment store, so the language stays one. Every count is a maximum. Readable structured metadata as ajsonblock kind is a stated suggestion with pros and cons; there is no separate opaque field./v1/tenants,/v1/tenants/{id}/events,/v1/tenants/{id}/episodic-memory/*, one error handler, closed error-code set, no tracebacks in responses.memmachine schema upgrade(an operator's command under a database lock, serving nothing) is the only thing that runs DDL that is not tenant-specific and also provisions vector containers;serveverifies and fails on mismatch; expand/contract for rolling deployments. Tenant-specific DDL is the only DDL allowed elsewhere, and no store in this design uses it.Component specifications
design/components/: one file per component (tenant service with registry, jobs, reconciler and sweep; key registry; event store; segment store; vector store;EpisodicMemory;EpisodicMemoryManager; context; blocks; ingest service; filters and properties; server and settings), each with its API, storage, fencing, settings and the changes required of an existing component; identifiers are typedUUIDthroughout. Lifecycle transitions and job steps are serialized on the tenant row's lock, and race matrices tabulate every concurrent pair on a tenant and on the data path. Every SQL-backed component's schema is given with SQLAlchemy types mapped to PostgreSQL and SQLite, constraints and indexes. The event store is a per-tenant log of additions and deletions with commit-ordered positions that subsystems replay, which is what makes every acknowledged client write reach every subsystem at least once; the job table and the log are the two queues, and neither is a message broker. Jobs recordlast_run_at,last_outcomeandattempts, and eligibility is computed at claim from the settings rather than stored as anext_run_atprescription. Every contract is an ABC, and every store is two of them: the store, the only place a key is named, for lifecycle and for constructing handles; and the handle (EventPartition,SegmentPartition,VectorCollection), the data surface, bound to one key at construction with no method taking a key, so a data consumer can reach neither lifecycle nor a wrong key.EpisodicMemorygainsexpand, the neighbourhood of a segment or event in the tenant's one total order. Scores are cosine similarity;SimilarityMetricgoes. Job kinds are four (provision;delete, the unlink, one call;sweep, purging batch by batch until nothing remains, reset by the tombstone pass for late garbage;replay), all defined by the tenant service.One naming scheme for values: settings (deployment), tenant configuration (per tenant, with options), templates, overrides, defaults, partition configuration (per key), request parameters, job arguments; in identifiers
SettingsandConfig, Python's conventions;conf,params,argsandpayloadare not identifiers in new code.What must be built first
A section names what the first deployment freezes and so must precede the first tenant (the key scheme, including the physical key inside each store, and the tenant table with tombstones; the event store with positions; stored-record conventions; a registry row per vector key; the public surface; Alembic from the first migration), what must land before churn, what can follow at any time, and the narrowest first deployment.
Relation to issues
Listed in the document's "Relation to open issues": #1548, #1530, #1531, #1571, #1575, #1576, #1577, #1572, #1573, #1564, #1565, #1537, #1563, #1535, #1570, #1542, and #1574 as the tracking issue.
Open questions
Hierarchy (flat proposed); synchronous in-request processing with
catch_upas repair versus a queue; event size limits; readable metadata as ajsonblock kind or a designed field; retention as a job kind.🤖 Generated with Claude Code
https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5