MemoryPolicy CRD
MemoryPolicy defines how the agentic memory store behaves across the
institutional, agent, and user tiers: when memories expire, how the
read path ranks and decays results, how the write path deduplicates, and how the
shared-write gate publishes to the shared tiers.
Policies are reusable documents — a single policy can be referenced by many
workspaces.
Expiry (tiers), the write path (dedup, schema) and the recall response
shape (recall.inlineThresholdBytes, recall.maxRelatedPerMemory) apply in the
Core edition. The rest is Enterprise-gated: the recall ranking settings
(recall.halfLife, recall.graphExpansion, tierPrecedence), the
institutional tier and document ingestion, the shared-write gate, and
per-user consent revocation (consentRevocation.action). Those sections are
marked below.
API Version
Section titled “API Version”apiVersion: omnia.altairalabs.ai/v1alpha1kind: MemoryPolicyShort name: mp (e.g. kubectl get mp).
Resource Scope
Section titled “Resource Scope”MemoryPolicy is cluster-scoped. There is no metadata.namespace.
How Policies Are Bound
Section titled “How Policies Are Bound”A MemoryPolicy is bound to a service group through
Workspace.spec.services[].memory.policyRef. See Configure workspace memory
for the field in context.
Each memory-api resolves the policy through its own Workspace and service group, and re-reads it at most every 30 seconds, so most spec changes reach the read and write paths without a restart.
The dashboard lists the policies under Settings → Memory policies, with
each one’s phase and the service groups that bind it, and opens a form over
its spec. A platform admin sees every policy; anyone else sees only the
policies bound in a workspace they can see. Only a platform admin may create
or change one, because a change reaches every workspace that binds the policy.
Fields the form does not show (the schema, allowed terms, graph expansion)
are saved back unchanged. What retention removed (expired, purged from the
trash, deleted on a consent revocation; all permanent deletes) shows on the
Memories page, read from memory-api’s GET /api/v1/memories/retention.
A service group with no policyRef (or one naming a policy that does not
exist) runs with no policy: the read and write paths use their built-in
defaults, and a memory expires only when its writer or memory-api’s
--default-ttl gives it an expiry.
Spec Fields
Section titled “Spec Fields”tiers (required)
Section titled “tiers (required)”Per-tier expiry. The institutional tier is Enterprise; Core deployments use user and agent (see Installing a License).
spec: tiers: agent: ttl: { default: "365d" } user: ttl: { default: "180d", maxAge: "365d" }Each tier (institutional, agent, user) takes these fields:
| Field | Type | Default | Description |
|---|---|---|---|
ttl | object | — | Write-time expiry (see below). |
perCategory | map | — | Per-consent-category expiry overrides (see below). |
A tier left out of tiers, or with no ttl, sets no expiry. User-for-agent
memories follow the user tier’s config.
An expired memory is not recalled, listed or opened. memory-api’s
maintenance worker deletes expired memories on every tier on its schedule
(--maintenance-schedule, hourly by default). Other memories leave by an
explicit memory__forget, a user “forget everything”, a consent revocation or
a DSAR delete.
tiers.<tier>.ttl
Section titled “tiers.<tier>.ttl”| Field | Type | Default | Description |
|---|---|---|---|
default | duration | — | Applied at write time when the caller sets no explicit expires_at. Empty falls back to memory-api’s global --default-ttl; when that is also unset, the memory does not expire. |
maxAge | duration | — | Caps any expires_at (explicit or defaulted) at write time, and caps the expiry a merge of near-duplicates reaches. Empty means no cap. |
The controller rejects a default longer than maxAge.
Durations are Go-duration-style strings matching ^([0-9]+d)?([0-9]+h)?([0-9]+m)?([0-9]+s)?$, e.g. 180d, 720h.
tiers.<tier>.perCategory
Section titled “tiers.<tier>.perCategory”Overrides the tier’s expiry for specific consent categories (for example
memory:health). Keys are consent-category strings. A ttl block on the
override replaces the tier’s for write-time expiry; without one the tier’s
applies:
spec: tiers: user: ttl: { default: "180d" } perCategory: memory:health: ttl: { default: "30d" } # health memories expire soonerrecall
Section titled “recall”Tunes the read path. Optional — defaults baked into the recall SQL apply when omitted.
| Field | Type | Default | Description |
|---|---|---|---|
halfLife | object | user 30d, agent 90d, institutional none | Per-tier recency-decay half-life: user, agent, institutional, each a Go duration (720h = 30 days). Enterprise. |
inlineThresholdBytes | int32 | 8192 | Body-size cutoff above which a recall request that sets preview returns title + summary + content_preview instead of the full body. The agent runtime does not set preview, so agents receive full bodies. Range 0–1048576. |
maxRelatedPerMemory | int32 | 3 | Caps the per-memory related[] slice in the recall response. Range 0–50. |
graphExpansion | object | — | Structural graph walk from direct recall hits (see below). Enterprise. |
See Memory for how half-life folds into the fused retrieval score.
recall.graphExpansion
Section titled “recall.graphExpansion”| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Expand direct hits over structural relations. A recall request can override this. |
hops | int32 | 1 | Maximum hops. Range 1–2. |
relationTypes | string[] | all | Relation types to follow. |
maxAdded | int32 | 10 | Cap on expanded rows appended to the direct results. Range 1–100. |
weight | string (0–1) | "0.6" | Score multiplier applied to expanded candidates before merging. |
Tunes the write path’s two dedup mechanisms. Optional.
| Field | Type | Description |
|---|---|---|
requireAboutForKinds | string[] | Kinds (e.g. fact, preference) that must carry an about={kind, key} hint on save. Unioned with memory-api’s --require-about-for-kinds flag; empty in both disables the check. |
embeddingSimilarity | object | Cosine-similarity dedup config (see below). Auto-disabled when no embedding provider is configured. |
dedup.embeddingSimilarity
Section titled “dedup.embeddingSimilarity”| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Whether cosine-similarity dedup runs. false: every save inserts a new memory, nothing is merged and no potential_duplicates are returned. |
autoSupersedeAbove | string (0–1) | 0.95 | Cosine ≥ this merges the save into its closest match. Lower merges more near-duplicates automatically. Must be above 0.5. |
surfaceDuplicatesAbove | string (0–1) | 0.85 | Cosine ≥ this surfaces the match as potential_duplicates for the agent to consider later. |
candidateLimit | int32 | 5 | Caps the matches compared and returned in potential_duplicates. Range 0–50; 0 uses the default. |
CEL rules at admission:
surfaceDuplicatesAbovemust be strictly less thanautoSupersedeAbovewhen both are set.autoSupersedeAbovemust be above0.5. The rule checks a new policy and any update that changes the field. On Kubernetes 1.30 and later, CRD validation ratcheting lets you keep updating a policy that already holds a lower value while that value is unchanged. On older clusters, raise the value in the same update. memory-api ignores a value of0.5or less and merges at0.95.
An autoSupersedeAbove below the effective surfaceDuplicatesAbove
merges every match it reads, so none is only surfaced.
A merge reinforces the existing memory: the newer wording replaces the body, a stated confidence replaces
the memory’s, and otherwise confidence combines as 1 − (1 − old)(1 − 0.7)
(capped at 0.99, never lowered), the expiry moves to the later of the two,
and evidence_count goes up by one, at most once an hour per memory. The
expiry is capped at the tier’s ttl.maxAge. The comparison
covers one user’s memories with the same container, agent scope and evidence
class. memory-api reads these fields on every save through its policy cache,
so a change applies within 30 seconds without a restart. Merging happens at
save time only; pairs already stored are not merged when the threshold
drops. See
Near-duplicates merge automatically.
Bounds what one memory write may carry. Optional.
| Field | Type | Default | Description |
|---|---|---|---|
maxBodyBytes | int32 | 8192 | Largest body, in bytes, one memory may hold. A write above it is rejected, never truncated; store the content elsewhere and attach it as a reference. Document ingest is exempt, because it is already chunked. Range 0–1048576; 0 uses the default. |
schema
Section titled “schema”Declares the vocabulary of the memory graph: the types a memory node may
have, the tiers each type may be written to, and the relations that may
connect two nodes. memory-api enforces it on every write path and serves it at
GET /api/v1/memory/schema so agents, the MCP gateway and the dashboard all
see the same vocabulary. It also renders the types and relations into the
agent’s memory tool descriptions (GET /api/v1/memory/tool-guidance), together
with dedup.requireAboutForKinds — see
Memory tools.
spec: schema: version: "policies.v2" types: - name: policy displayName: Policy description: A governing policy document. allowedTiers: [institutional] - name: control displayName: Control allowedTiers: [institutional] - name: fact allowedTiers: [institutional, agent, user, user_for_agent] relations: - name: implements kind: logical inverse: implemented_by sourceTypes: [control] targetTypes: [policy] - name: related_to kind: association directed: false sourceTypes: ["*"] targetTypes: ["*"]schema.types[]
Section titled “schema.types[]”| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | The value carried in a memory’s type (and a batch item’s kind). |
displayName | string | no | Label the dashboard shows for the type. |
description | string | no | Explains the type. Agents see it in the memory__remember tool’s type parameter. |
allowedTiers | string[] | no | Tiers the type may be written to: institutional, agent, user, user_for_agent. Empty means every tier. |
schema.relations[]
Section titled “schema.relations[]”| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | yes | — | The relation type stored on the edge (relation_type). This is the value callers pass when creating an edge and the value recall.graphExpansion.relationTypes and a recall request’s relation_types filter on. |
kind | enum | yes | — | The relation’s category: containment, sequence, reference, provenance, logical, association or equivalence. Served with the schema for consumers to interpret. |
inverse | string | no | — | Name of the reverse reading (for example part_of / contains). Served with the schema; no edge is stored for it. |
sourceTypes | string[] | no | any | Types allowed at the edge’s source. "*" or an empty list matches any type. |
targetTypes | string[] | no | any | Types allowed at the edge’s target. Same matching as sourceTypes. |
directed | bool | no | true | Whether source → target order is meaningful. Served with the schema. Graph expansion walks edges in both directions regardless. |
How the schema is enforced
Section titled “How the schema is enforced”| Write path | With a policy schema | Without one |
|---|---|---|
POST /api/v1/memories, agent-scoped saves | type must be declared, and its allowedTiers must include the tier the write’s scope resolves to. The tier is user_for_agent when the scope has a user and an agent, user when it has only a user, agent when it has only an agent, and institutional otherwise. | Validated against the built-in schema (below). |
Single institutional write (POST /api/v1/institutional/memories) | type must be declared and allowed in institutional. | Not validated. |
Batch ingest (POST /api/v1/institutional/ingest/batch) | Every document must be allowed as type document. One violation rejects the whole batch. | Not validated. |
A violation answers 400 with a message naming the rule.
Declaring a schema replaces the built-in one; nothing is merged. A policy
that declares only institutional types therefore rejects every user-tier save,
including the general, fact, preference and observation types the agent
memory tools produce. The
SchemaCoverage condition warns when a configured tier has no type that allows
writes to it.
The built-in schema
Section titled “The built-in schema”Without a policy schema, GET /api/v1/memory/schema returns version
builtin.v1:
| Type | Allowed tiers |
|---|---|
document, section, chunk | institutional |
general, fact | all tiers |
preference, observation, temporal_summary | user, user_for_agent, agent |
It also lists two relations:
part_of(containment, inversecontains): from asectionorchunkto adocumentorsection.next(sequence, inverseprevious): betweensectionandchunknodes, in reading order.
See Memory for where the schema reaches agents, the MCP gateway, and recall.
tierPrecedence (Enterprise)
Section titled “tierPrecedence (Enterprise)”Applies per-tier ranking multipliers to the fused retrieval score.
multiplicative is the only ranking strategy and must be set when
tierPrecedence is present — enforced by CEL at admission.
| Field | Type | Default | Description |
|---|---|---|---|
multiplicative.institutional | string (0–10) | "1.0" | Weight applied to institutional-tier scores. |
multiplicative.agent | string (0–10) | "1.0" | Weight applied to agent-tier scores. |
multiplicative.user | string (0–10) | "1.0" | Weight applied to user-tier scores. user_for_agent inherits this weight. |
spec: tierPrecedence: multiplicative: institutional: "2.0" agent: "1.5" user: "1.0"gate (Enterprise)
Section titled “gate (Enterprise)”Configures the shared-write gate, which decides what reaches the shared
agent and institutional tiers.
| Field | Type | Default | Description |
|---|---|---|---|
dedupThreshold | string (0–1) | "0.95" | Cosine similarity at or above which a candidate counts as the same fact as an existing shared item (added to it as evidence) or as a held candidate (counted towards corroboration). |
allowedTerms | []string | — | The workspace’s own product, team and system names that de-identification treats as non-personal. Never a person’s name. |
minDistinctSubjects | int32 (2–1000) | 2 | How many distinct users must stand behind a fact before it is published. |
corroborationWindowDays | int32 (1–365) | 30 | How long a held fact waits for corroboration before it is rejected as uncorroborated and its text deleted. |
accept.userShares | bool | true | Admit users’ shares (memory__remember with share: true). When false they are rejected as share_disabled, and the user’s private copy stays. |
accept.dreaming | bool | true | Admit Dreaming runs’ proposals. When false they are rejected as dreaming_disabled. |
rateLimits.maxHeldPerSubject | int32 (1–100000) | 200 | How many shares one user may have waiting for corroboration. A share beyond it is rejected as rate_limited. |
rateLimits.maxPublishesPerHour | int32 (1–100000) | 100 | How many items the gate may publish in any hour. Beyond it, candidates stay pending until the hour rolls over. |
canaries.onHit | freeze | reject | freeze | What a canary hit does (below). |
canaries.clearedAt | timestamp | — | Clears a freeze that happened before it. |
Corroboration. One user can never put a fact into shared memory alone.
A user’s shared memory stays in their own tier, which they see at once. The
gate holds the shared copy until enough other users have shared the same
fact, matched at dedupThreshold, and then publishes one de-identified item
with all of them as evidence. A Dreaming proposal must cite items from at
least minDistinctSubjects users. Three cases need no corroboration:
- knowledge derived only from items that are already shared;
- items the brain migration re-queues, which were shared before it ran;
- a near-duplicate of an item already published, which only adds evidence.
A held fact keeps its raw text until it is published or expires. Erasing the user deletes it.
Canaries. The gate plants three made-up facts per workspace, such as “the amber vault override code is EMBER-0042”. They are private memories of a hidden subject that no person or agent can act as. Nothing legitimate can carry one to the gate. Only something that reads one user’s memories and promotes them can, such as a misbehaving Dreaming run or a leak in a cross-user path. The gate checks every candidate in two ways:
- Certain match: the canary subject is among its sources, or it contains a
canary’s text or code. The candidate is rejected as
canary. WithonHit: freezethe gate also stops deciding anything until an admin clears it. WithonHit: rejectit only rejects and alerts. - Near match: only its vector is as close to a canary as
dedupThreshold. The candidate is rejected and alerted, but the gate never freezes.
omnia_memory_gate_canary_hits_total and omnia_memory_gate_frozen carry
the alert. Canaries never appear in recall, Galaxy, export or erasure, and no
caller or Dreaming run can act as their subject. Codes are matched as whole
words, so member-0042 does not match EMBER-0042.
To clear a freeze after investigating it, set clearedAt to the current
time. A clear up to two minutes behind the database’s clock still counts.
Each queued candidate that carries a canary freezes the gate again when it is
reached, which is intended: each one is evidence.
kubectl --context <ctx> -n <workspace-namespace> patch memorypolicy <name> --type merge \ -p '{"spec":{"gate":{"canaries":{"clearedAt":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}}}}'spec: gate: minDistinctSubjects: 3 corroborationWindowDays: 14 accept: userShares: true dreaming: true rateLimits: maxHeldPerSubject: 200 maxPublishesPerHour: 100 canaries: onHit: freezeconsentRevocation
Section titled “consentRevocation”How memory-api reacts when a user toggles off a consent category. When a user
revokes a category, privacy-api posts the event to each memory-api’s
POST /api/v1/memories/consent-events, and memory-api applies action to that
user’s memories in that category. With no policy bound the action is
SoftDelete.
| Field | Type | Default | Description |
|---|---|---|---|
action | enum | SoftDelete | One of SoftDelete, HardDelete, Stop. Enterprise. |
| Action | Behaviour |
|---|---|
SoftDelete, HardDelete | Delete the user’s memories in the category at once. |
Stop | Leave existing memories alone. Not GDPR-compliant in most jurisdictions. |
Whatever the action, new writes in a revoked category are refused: memory-api checks each write’s category against privacy-api’s consent grants.
ingestion (Enterprise)
Section titled “ingestion (Enterprise)”Configures how source documents become index items at /institutional/ingest and /institutional/ingest/batch.
Read live by memory-api at ingest time; the --ingest-* binary flags are the
fallback when a field is unset.
| Field | Type | Default | Description |
|---|---|---|---|
strategy | enum | chunk | One of chunk (RAG-chunk the raw text), summary (one condensed summary per document), summaryThenChunk (summarize, then RAG-chunk the summary). |
summarizer | enum | extractive | extractive (in-process lead-sentence, no LLM) is the only backend. Ignored when strategy: chunk. |
chunk | object | — | RAG chunk-splitter geometry: size (words, default 200) and overlap (words, default 40). overlap must be less than size (CEL-enforced). |
toolGuidance
Section titled “toolGuidance”Adds workspace advice to the instructions the model reads for the memory tools.
memory-api derives those instructions from the rest of this policy (schema,
dedup, recall) and serves them at GET /api/v1/memory/tool-guidance; see
Memory tools.
Notes are appended after the derived text under “Notes for this workspace:”
and never replace it, so a note cannot contradict how the store behaves.
| Field | Type | Default | Description |
|---|---|---|---|
notes | string | — | Appended to every memory tool’s description. At most 2000 characters. |
tools.remember | string | — | Appended to memory__remember’s description, after notes. At most 2000 characters. |
tools.recall | string | — | Appended to memory__recall’s description. At most 2000 characters. |
tools.list | string | — | Appended to memory__list’s description. At most 2000 characters. |
tools.forget | string | — | Appended to memory__forget’s description. At most 2000 characters. |
tools.open | string | — | Appended to memory__open’s description. At most 2000 characters. |
spec: toolGuidance: notes: "Never store payment card numbers or passwords." tools: remember: "Record support context with purpose: support_context."The notes are sent to the model in every conversation that has memory tools,
so keep them short. An agent can add its own advice with
AgentRuntime.spec.memory.tools.notes, which follows these.
Status Fields
Section titled “Status Fields”| Field | Type | Description |
|---|---|---|
status.phase | string | Active (spec validated) or Error (validation failed — see conditions). |
status.observedGeneration | int64 | Spec generation last reconciled. |
status.conditions | Condition[] | See below. |
| Condition | Meaning |
|---|---|
PolicyValid | True when the spec passes the controller’s checks (durations, tierPrecedence weights in 0–10, ttl.default ≤ ttl.maxAge). False sets phase: Error. |
SchemaCoverage | False (a warning, not an error) when a configured tier has no schema type that allows writes to it — writes to that tier will be rejected. |
WorkspacesResolved | Always True with reason NotApplicable. |
Ready | True when the policy validated. |
Examples
Section titled “Examples”Minimal policy — expiry on the user tier
Section titled “Minimal policy — expiry on the user tier”apiVersion: omnia.altairalabs.ai/v1alpha1kind: MemoryPolicymetadata: name: basicspec: tiers: user: ttl: default: "180d"Full policy
Section titled “Full policy”apiVersion: omnia.altairalabs.ai/v1alpha1kind: MemoryPolicymetadata: name: researchspec: tiers: agent: ttl: default: "365d" user: ttl: default: "180d" maxAge: "365d" perCategory: memory:health: ttl: default: "30d" recall: halfLife: # Enterprise user: "720h" agent: "1440h" institutional: "8760h" inlineThresholdBytes: 2048 maxRelatedPerMemory: 3 dedup: requireAboutForKinds: - fact - preference embeddingSimilarity: enabled: true autoSupersedeAbove: "0.95" surfaceDuplicatesAbove: "0.85" tierPrecedence: # Enterprise multiplicative: institutional: "2.0" agent: "1.5" user: "1.0" consentRevocation: action: HardDelete ingestion: # Enterprise strategy: summaryThenChunk summarizer: extractive chunk: size: 200 overlap: 40Related Resources
Section titled “Related Resources”- Memory — the multi-tier recall model, RRF fusion, and half-life decay explained.
- Change the memory embedding model — move memory to a different embedding model.
- Workspace CRD —
spec.services[].memory.policyRef.