# 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

```yaml
apiVersion: omnia.altairalabs.ai/v1alpha1
kind: MemoryPolicy
```

**Short name:** `mp` (e.g. `kubectl get mp`).

## Resource Scope

`MemoryPolicy` is **cluster-scoped**. There is no `metadata.namespace`.

## How Policies Are Bound

A `MemoryPolicy` is bound to a service group through
`Workspace.spec.services[].memory.policyRef`. See [Configure workspace memory](/how-to/memory/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

### `tiers` (required)

Per-tier expiry. The **institutional** tier is Enterprise; Core deployments use
**user** and **agent** (see
[Installing a License](/how-to/operations/install-license/)).

```yaml
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`

| 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`

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:

```yaml
spec:
  tiers:
    user:
      ttl: { default: "180d" }
      perCategory:
        memory:health:
          ttl: { default: "30d" }   # health memories expire sooner
```

### `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](/explanation/agents/memory/) for how half-life folds into the fused
retrieval score.

#### `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. |

### `dedup`

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`

| 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:

- `surfaceDuplicatesAbove` must be **strictly less than** `autoSupersedeAbove`
  when both are set.
- `autoSupersedeAbove` must be above `0.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 of `0.5` or
  less and merges at `0.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](/explanation/agents/memory/#near-duplicates-merge-automatically-and-reinforce).

### `write`

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`

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](/reference/platform/memory-tools/#where-the-tool-descriptions-come-from).

```yaml
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[]`

| 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[]`

| 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

| 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

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`, inverse `contains`): from a `section` or `chunk` to a `document` or `section`.
- `next` (`sequence`, inverse `previous`): between `section` and `chunk` nodes, in reading order.

See [Memory](/explanation/agents/memory/#the-memory-schema-in-use) for where the schema reaches agents,
the MCP gateway, and recall.

### `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. |

```yaml
spec:
  tierPrecedence:
    multiplicative:
      institutional: "2.0"
      agent: "1.5"
      user: "1.0"
```

### `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`. With
  `onHit: freeze` the gate also stops deciding anything until an admin clears
  it. With `onHit: reject` it 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.

```bash
kubectl --context <ctx> -n <workspace-namespace> patch memorypolicy <name> --type merge \
  -p '{"spec":{"gate":{"canaries":{"clearedAt":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}}}}'
```

```yaml
spec:
  gate:
    minDistinctSubjects: 3
    corroborationWindowDays: 14
    accept:
      userShares: true
      dreaming: true
    rateLimits:
      maxHeldPerSubject: 200
      maxPublishesPerHour: 100
    canaries:
      onHit: freeze
```

### `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)

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`

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](/reference/platform/memory-tools/#where-the-tool-descriptions-come-from).
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. |

```yaml
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

| 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

### Minimal policy — expiry on the user tier

```yaml
apiVersion: omnia.altairalabs.ai/v1alpha1
kind: MemoryPolicy
metadata:
  name: basic
spec:
  tiers:
    user:
      ttl:
        default: "180d"
```

### Full policy

```yaml
apiVersion: omnia.altairalabs.ai/v1alpha1
kind: MemoryPolicy
metadata:
  name: research
spec:
  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: 40
```

## Related Resources

- [Memory](/explanation/agents/memory/) — the multi-tier recall model, RRF fusion, and
  half-life decay explained.
- [Change the memory embedding model](/how-to/memory/change-memory-embedding-model/) —
  move memory to a different embedding model.
- [Workspace CRD](/reference/core/workspace/) —
  `spec.services[].memory.policyRef`.