Eight dials, one retention story.

Every memory has a lifetime — minutes, days, or forever. ai-memory exposes eight independent TTL dials from per-write override to daemon-wide config to namespace policy to GC cadence. Default behavior covers most use cases. When it doesn't, every dial is reachable from one of three layers: the memory itself, the daemon config, or the namespace standard.

3 tier defaults 2 per-write overrides 3 lifecycle paths since v0.1
The eight dials

Where retention is set, in priority order.

Higher dials win. A per-write expires_at overrides everything. Tier defaults fill in the gaps. Daemon config customizes the tier defaults globally. Namespace standards (v0.7 spec §3) will let policy enforce caps; today the gate is governance-on-promote.

expires_atper-write · explicit · highest priority
Set an exact RFC3339 timestamp. The memory is GC'd as soon as the cycle finds it past that time. Strict on writes (rejects past dates via validate_expires_at); lenient on updates (validate_expires_at_format).
{"title": "…", "content": "…", "expires_at": "2026-12-31T23:59:59Z"}
ttl_secsper-write · convenience
Caller-friendly: expires_at = now() + ttl_secs. Validated: must be positive, ≤ 31 536 000 (1 year — caps "accidental immortality" from typos). When both expires_at and ttl_secs are set, expires_at wins.
{"title": "working note", "ttl_secs": 3600} // expires in 1h {"title": "sprint goal", "ttl_secs": 1209600} // 14d {"title": "oops", "ttl_secs": 99999999} // REJECTED — exceeds 1 year
tier (default TTL)per-write · per-tier defaults
If neither expires_at nor ttl_secs is set, the tier's default applies: Short = 6h, Mid = 7d, Long = no TTL. The default tier is Mid if you don't pick one.
{"title": "…", "tier": "short"} // → expires_at = now() + 6*3600 {"title": "…", "tier": "mid"} // → expires_at = now() + 7*86400 {"title": "…", "tier": "long"} // → expires_at = NULL (durable) {"title": "…"} // → tier="mid", 7d default
[ttl] config — short_ttl_secsdaemon-wide · overrides Short default
Set in config.toml under [ttl]. Applies to every Short-tier memory created by this daemon. 0 disables expiry for the tier. Useful when the operator wants tighter or looser short-term retention than the 6h default.
[ttl] config — mid_ttl_secsdaemon-wide · overrides Mid default
Same shape as short_ttl_secs but for Mid tier. Common override: extend Mid to 30 days (2592000) for longer project cycles.
[ttl] config — long_ttl_secsdaemon-wide · rare
Long defaults to no TTL (None). Setting this turns Long into a finite tier — useful for compliance scenarios where "forever" is too long. 0 = no TTL (matches default).
# config.toml [ttl] short_ttl_secs = 3600 # 1h instead of 6h mid_ttl_secs = 2592000 # 30d instead of 7d long_ttl_secs = 31536000 # 1 year (compliance: "no forever") short_extend_secs = 1800 # bump expiry +30min on access mid_extend_secs = 86400 # bump expiry +1d on access
extend_secs (access-driven)on read · per-tier
When a memory is accessed (memory_get, recall hit, search hit), its expires_at bumps forward by the tier's extend_secs. Memories that get used keep earning more time. Defaults: Short +1h, Mid +1d. Long has no extend (no TTL to extend).
archive_on_gcdaemon-wide · default true
Boolean. true (default): expired memories move to archived_memories — restorable. false: hard delete on expiry — gone. The archive table itself can be auto-purged via auto_purge_archive(max_days) for a soft-then-hard retention pattern.
# config.toml — soft-delete pattern (recoverable for 30 days) archive_on_gc = true auto_purge_archive_days = 30 # config.toml — hard-delete pattern (compliance: irreversible erasure on TTL) archive_on_gc = false
The GC cycle

How memories get evicted.

Garbage collection is on-demand and observable. There's no magic background daemon — the operator decides when GC runs, either via the MCP memory_gc tool, the HTTP /api/v1/gc endpoint, or the curator cycle (src/curator.rs). Each invocation logs how many rows it touched.

memory_gc tick · cycle · operator-driven SELECT WHERE expires_at < now() walks all tiers · Long never matches archive_on_gc = true row → archived_memories archive_on_gc = false DELETE FROM memories Restorable memory_archive_restore → back to memories table memory_archive_purge irreversible · audit-friendly Gone FTS evicted · HNSW evicted Federation peers reconciled
memory_forget

Pattern-based bulk archival.

When you don't want to wait for TTL — sweep on demand. memory_forget takes a namespace + FTS pattern + optional tier filter, archives every match, returns the count. Same effect as letting them expire, but you decide when.

// MCP — memory_forget { "namespace": "alphaone/eng/platform", "pattern": "draft notes", // FTS5 expression "tier": "short" // optional — restrict to Short tier } → {"forgotten": 42, "dry_run": false}

Forget vs. delete vs. forget-pattern:

delete
memory_delete
Single id. Goes through governance. Archive or hard-delete (per archive_on_gc).
forget
memory_forget
Pattern-based bulk archive. No governance gate (it's an operator broom). Always archives — restorable.
gc
memory_gc
TTL-driven sweep. Archives or hard-deletes per archive_on_gc. Operator-invoked or curator-cycle-driven.
Default behavior

What happens out of the box.

// Operator does nothing — accepts every default tier = mid // default tier mid_ttl_secs = 7 * 86400 // 7 days short_ttl_secs = 6 * 3600 // 6 hours long_ttl_secs = None // no TTL — durable extend_secs = +1h Short, +1d Mid // access bumps expiry archive_on_gc = true // expired → archived (restorable) auto_purge = none // archived rows kept until explicit purge // Result: working notes age out in 6 hours, project context in 7 days, // long-tier knowledge sticks until you say otherwise. Anything that ages // out lands in the archive — no data loss until an operator runs purge.