Durable record contracts
Collections
A collection is where your records live. Think of it as a labeled drawer in a filing cabinet: everything in the drawer follows the same rules. The collection definition is where you write those rules down — what a valid record looks like, whether records are diary-style entries or named slots that get updated, which enrichment must run on each record, and how the records are searched.
Collection files live in collections/*.yaml. A file starts with a
collections: list and may contain several collections.
If entity, key, current, or ready are unfamiliar, pause at the Glossary. Those terms determine the collection shape before any YAML field does.
Start by describing what you want to remember¶
Before writing any YAML, say it in words. For example:
"I run a support desk. I want to keep every email, call, and note about each customer, exactly as it happened, and never edit old entries. Each entry must say which channel it came from, and I want to filter searches by channel later. Separately, I want a short profile of each customer — their needs, commitments, risks — where each part can be updated over time."
That description maps directly to two collections: an event collection for the raw entries, and a keyed collection for the profile.
collections:
# "Keep every email, call, and note, exactly as it happened."
- name: customer_events
version: 1
active: true
mode: event # append-only: entries are never edited
schema: # what a valid entry looks like
type: object
required: [text, channel] # every entry needs text and a channel
properties:
text: {type: string}
channel: {type: string, enum: [email, call, note]}
additionalProperties: true
fields: # "I want to filter by channel later"
channel:
path: content.channel
type: string
filter: true
required_processors: [embedding_v1, importance]
search_profile: pg_default
# "A short profile per customer, where each part can be updated."
- name: customer_profiles
version: 1
active: true
mode: keyed # named slots: one current value per key
schema:
type: object
required: [text]
properties:
text: {type: string}
tombstone: {type: boolean} # system-owned retraction marker; see below
additionalProperties: true
required_processors: [embedding_v1]
search_profile: pg_default
The rest of this page explains every field you can put in a collection, what it does, and when you would use it.
Every field, explained¶
name (required)¶
The permanent, public name of the collection. Everything else — derivations, views, packages, API calls — refers to the collection by this name, so choose it the way you would choose a database table name: short, lowercase, and descriptive of the content, not the current use case.
Names must start with a lowercase letter and may contain lowercase letters,
digits, ., _, and -, up to 64 characters (customer_events is fine;
CustomerEvents and 2024_events are not).
version (required)¶
A positive whole number identifying this revision of the contract: 1, 2,
3, … Records permanently store the collection name and version they were
written under, so old data never silently changes meaning. When you change
what records mean — new required properties, different field types — add a new
version instead of editing the old one. See the
versioning checklist below, and
which "latest" is which for
how definition versions relate to record supersession.
active (default false)¶
Marks which version is the default when someone refers to the collection
without a version (just customer_events instead of customer_events@2).
Exactly one version of a name can be active at a time.
When do you need to think about this? For a brand-new collection, never —
you have one version, and you mark it active: true. The flag earns its keep
during a migration: you add customer_events version 2 with the new schema
and active: true, and flip version 1 to active: false but keep it in
the catalog. New records land in version 2; old records still validly belong
to version 1, and anything that pinned customer_events@1 keeps working. If
you deleted version 1 instead, replacing the catalog would fail because
existing records would lose their contract.
mode (required)¶
The shape of the records. This is the most important choice you make:
event— append-only entries, like lines in a journal. You never edit an old entry; you only add new ones. Use this for things that happened: messages, meeting notes, log lines, observations.keyed— named slots, like labeled sticky notes on a whiteboard. Each entity has one current value per key (for example keyneedsorcommitments). Writing a new value for the same key supersedes the old one — the old value is kept as history, but searches see only the current one by default. Use this for things that are true right now: profiles, skills, preferences.mixed— the collection accepts both records with a key and records without one.
mode: event # a diary: "what happened"
mode: keyed # a whiteboard: "what is currently true"
mode: mixed # both forms accepted
How to choose. Ask: "if the same information arrives twice, do I want two entries, or an update?"
- Two support emails → two entries. That is
event. You needeventwhenever losing or overwriting an entry would lose history you care about: audit trails, conversations, observations an agent might re-analyze later. - "The customer's current plan is Enterprise" → an update. That is
keyed. You needkeyedwhenever downstream consumers should see one answer per question, not every answer ever given — profiles, settings, skills, the output of most derivations. The key (plan,needs,risks) is the name of the question; the record is the current answer. - You need
mixedless often: typically when a derivation writes keyed state into the same collection where free-form events also land, or during a gradual migration. When in doubt, keep events and state in two separate collections — it keeps the contracts simpler.
schema (required)¶
A JSON Schema (Draft 2020-12)
describing the structured content of each record — the machine-checkable
part of "what a valid entry looks like." Memseek adds three rules on top of
the standard:
- The root must be an object (
type: object). - There must be a string property named
text. textmust be listed inrequired.
text is the human-readable sentence of the record and the default text that
gets indexed for search; everything else in content is structured payload.
A realistic calendar example:
"Every calendar entry has a description, a start time, and a list of attendee names. Nothing else is allowed."
schema:
type: object
required: [text, starts_at, attendees]
properties:
text: {type: string} # "Weekly sync with ACME"
starts_at: {type: string, format: date-time} # timestamps are strings with this format
attendees:
type: array
items: {type: string}
additionalProperties: false # reject entries with unexpected extras
Set additionalProperties: false when you want strict entries, or true when
callers may attach extra data you do not need to validate.
text_projection (optional)¶
A template that builds the searchable text out of the structured content,
when the raw text alone is not what you want indexed. Every {{...}}
reference must name a property declared in the schema.
"When searching, an event should read as its text plus the channel it came through."
If you omit this, the record's text is used as-is. That is the right choice
for most collections.
fields (optional)¶
Declares which parts of the structured content can be used in typed queries —
filtering (where), sorting (order_by), and projection (returning the value
with search results). Without a declared field, the data is still stored; it
just cannot be filtered or sorted on.
Each entry gives a public field name and says where the value lives and what you may do with it:
fields:
starts_at:
path: content.starts_at # where the value lives in the record
type: datetime # what kind of value it is
filter: true # allow "where starts_at >= ..." queries
sort: true # allow ordering results by this field
project: true # allow returning this value with results
attendees:
path: content.attendees
type: [string] # a list of strings
filter: true
project: true
| Option | Allowed values | What it does |
|---|---|---|
path |
content.foo or annotations.foo (dots for nesting) |
Where in the record the value lives. Must resolve to a matching property in the content schema (or an annotation's output schema). |
type |
string, number, integer, boolean, datetime |
The value's type. Wrap it in brackets — [string] — for a list of that type. datetime corresponds to a schema string with format: date-time. |
filter |
true / false (default false) |
Lets queries filter on this field with where. |
sort |
true / false (default false) |
Lets queries order results by this field. Lists cannot be sorted. |
project |
true / false (default false) |
Lets queries return this field's value alongside each result. |
Clients always use the field name (starts_at), never the raw path — the
path is an internal detail you can keep stable even if the name is public.
A field may also point into annotations. — values written by a processor
rather than by the client. If a trigger or derivation depends on such a field,
bind that processor as a required processor so the value is guaranteed to
exist before the record is used.
required_processors (default: none)¶
Names of processors (embedding, score, or json — see Processors) that must finish successfully before a record counts as ready. Ready means: visible to search, and able to feed triggers and derivations. Use this for enrichment the rest of the system depends on, such as the embedding used for semantic search:
The trade-off: every required processor delays readiness. Only require what your consumers truly need.
Requiring none is a legitimate and sometimes correct choice. A collection of feedback signals is the classic case: you want a signal to be able to set off reasoning the instant it is written, not after an embedding queue drains.
optional_processors (default: none)¶
Processors that run on each record too, but without holding up readiness — the record becomes searchable immediately and the enrichment fills in when it completes. Good for nice-to-have annotations like sentiment:
A processor cannot appear in both lists, and every processor named here must declare this collection in its own input scope.
search_profile (required)¶
The name of the search backend profile this collection uses by default, from
conf/search_profiles.yaml. If you are unsure, pg_default (the built-in
PostgreSQL profile) is the standard choice.
allowed_search_profiles (default: none)¶
Additional profiles a deployment operator is allowed to switch this collection to — for example, an external vector index that is only enabled when credentials exist:
answerable (default: false)¶
Whether POST /answer — and the answer MCP tool — may synthesize prose over this
collection.
Answering is the only read that writes new sentences out of several records rather than handing back what is stored. Because of that, which drawers it may open should be a decision you make deliberately — not something a collection gets by accident just because it has embeddings.
A collection can be perfectly searchable and still be a bad source for
synthesis. Raw transcripts, saved prompt snapshots, feedback signals, and
previously generated answers are all things you want to retrieve and cite,
not things you want a model paraphrasing back to a caller as fact. Turn
answerable: true on for your interpreted layers — profiles, reflections,
summaries — and leave it off everywhere else. If no collection in a workspace
declares it, answering returns 422 answer_unavailable.
Like the search-routing options, this is a binding rather than part of the record contract. Turning it on or off changes who may read a record, never what the record means, so it never strands stored data and never needs a new collection version.
How records enter a collection¶
A record request supplies collection, entity (whose memory this is —
the customer, user, or agent the record is about), type, text, optional
key, optional structured content, optional occurred_at, and an
idempotent dedupe_key. For keyed collections, key is the durable slot
name. Removing a keyed value is done by writing a successor (a retraction),
never by editing in place — history is always preserved. That successor is a
tombstone: a system-written record that marks the slot as no longer holding
a value. You declare the tombstone: {type: boolean} field in the schema so it
is known, but you never set it yourself — a derivation emits retract: true or
the API insert passes tombstone=true, and the runtime writes it. See
reacting to tombstones for the
full lifecycle.
Entities, types, and keys are chosen by you at write time, not declared in the collection — see Core concepts for how to choose them well.
When to create a new version¶
Records store the record contract of the version that admitted them: mode,
schema, text_projection, fields, and required_processors. Create a new
version when you change one of those in a way that would make an existing
record mean something different:
- adding a required property,
- retyping, re-pathing, or removing a declared field,
- redefining or removing a schema property,
- adding or removing a
required_processorsentry (readiness gates visibility), - changing
modeortext_projection.
Keep the old version present (and inactive) while records and packages still reference it, and list both in the package. Then move the old records forward with a migration derivation if you want the old version to retire.
Most edits are not in that list and publish over a live collection:
active,optional_processors,search_profile, andallowed_search_profilesare bindings, outside the contract entirely;- adding an optional property, declaring a
fieldsentry over it, relaxingadditionalPropertiesfromfalsetotrue, and reorderingrequired_processorsare provably additive — the publish moves stored records onto the new contract for you.
Writing a content key the schema does not name is free on any collection with
additionalProperties: true; declaring it is the additive publish above.
Run a preflight if you are unsure — it answers exactly this question against your real records:
See Changing definitions for the full matrix and how the additive cases are proved rather than assumed, or A migration, start to finish for one catalog followed through four changes.
Validation rules the loader enforces¶
These checks run when the catalog is loaded or uploaded, so mistakes fail fast instead of corrupting data:
- Collection names and versions must be unique; one active version per name.
required_processorsandoptional_processorsmust not overlap, and every named processor must exist and include this collection in its input scope.- The named
search_profile(and every allowed profile) must exist. - The schema root must be an object with a required string
text. - Every declared field path must resolve to a schema property of a compatible type.
- Every
{{...}}intext_projectionmust name a declared schema property.
One thing you cannot currently do: there is no free-text description field on
a collection. The name, the schema, and the field names are the contract, and
they are meant to read well enough to stand on their own. Adding a description
key of your own will fail the publish, because unknown keys are rejected —
document the intent in a YAML comment instead.