# How `memory_write_page` with `expires_at` Interacts with the Forget Sweep in ai-memory

> Learn how memory_write_page with expires_at interacts with the forget sweep. Understand how AI-memory automatically purges expired pages and their ancestry.

- Repository: [Fabio Akita/ai-memory](https://github.com/akitaonrails/ai-memory)
- Tags: internals
- Published: 2026-08-20

---

**`memory_write_page` stores an optional `expires_at` timestamp in the page's front-matter; once this timestamp passes, the page becomes invisible to queries, and the daily `memory_forget_sweep` job permanently deletes both the markdown file and its version ancestry from the SQLite store.**

The `ai-memory` system provides **time-bounded memory pages** through a simple but rigorous TTL mechanism. When you write a page with an expiration date, the system follows a three-stage lifecycle: active visibility, query filtering, and eventual hard deletion. This article explains exactly how these stages work according to the source code in `akitaonrails/ai-memory`.

## Writing a Page with `expires_at`

The `memory_write_page` function accepts an optional `expires_at` field in RFC 3339 format. According to the wiki crate implementation in [`crates/ai-memory-wiki/src/wiki.rs`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-wiki/src/wiki.rs), the `Wiki::write_page` method extracts this value from the page's front-matter and forwards it to the persistent store.

The value is stored as `Option<jiff::Timestamp>` in the **pages** table's `expires_at` column. This design keeps TTL metadata alongside the content without requiring separate indexing tables.

```bash

# Write a page that expires at end of 2026

mcp memory_write_page '{
  "path":"notes/project-plan.md",
  "title":"Project Plan",
  "body":"Milestones …",
  "expires_at":"2026-12-31T23:59:59Z"
}'

```

The `expires_at` field accepts any valid RFC 3339 timestamp. Omitting it creates a page with no expiration.

## Query Filtering: How Expired Pages Disappear

Once a page's `expires_at` timestamp passes, it immediately becomes **invisible to normal queries**. This happens through a defensive SQL filter applied on every read path.

In [`crates/ai-memory-store/src/reader.rs`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-store/src/reader.rs), all retrieval queries include this clause:

```sql
WHERE (pages.expires_at IS NULL OR pages.expires_at > :now)

```

This means:

- **Before expiry**: The page appears in `memory_query` results normally
- **After expiry**: The page is excluded from results as if it doesn't exist
- **The markdown file remains on disk** — only query visibility is affected

```bash

# Query before expiry — page is returned

mcp memory_query '{"query":"Project Plan"}'

# → contains the page

# Same query after 2027-01-01 — nothing returned

mcp memory_query '{"query":"Project Plan"}'

# → empty result set

```

This soft-expiry approach allows administrators to recover accidentally expired content before the sweep runs.

## The Forget Sweep: Hard Deletion of Expired Pages

The `memory_forget_sweep` job (M8 retention pass) performs the actual cleanup. Running daily by default, this server-side job follows a documented two-phase process.

### Phase 1: Tombstoning

Per [`docs/design-decisions.md`](https://github.com/akitaonrails/ai-memory/blob/main/docs/design-decisions.md) line 162:

> "Decay/forget runs as a separate `memory_forget_sweep` job: applies the retention formula; **removes the Markdown source while tombstoning via `is_latest=false` + `superseded_at`**"

This creates an audit trail before physical deletion.

### Phase 2: Ancestry Purge

After a configured grace period, the sweep **hard-deletes** the tombstone's full version ancestry. The architecture documentation in [`docs/ARCHITECTURE.md`](https://github.com/akitaonrails/ai-memory/blob/main/docs/ARCHITECTURE.md) line 368 confirms this: "purge aged tombstone ancestry, and **hard-delete TTL-expired pages**."

The sweep implementation resides in [`crates/ai-memory-mcp/src/server.rs`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-mcp/src/server.rs) within the `memory_forget_sweep` function.

```bash

# Preview what the sweep would delete (dry run)

mcp memory_forget_sweep '{"dry_run":true}'

# → lists pages with elapsed expires_at timestamps

# Execute permanent deletion

mcp memory_forget_sweep '{}'

# → markdown file removed, database records purged

```

## TTL Outranks `pinned`: Priority Rules

A critical edge case: **expiration overrides pinning**. The usage documentation in [`docs/usage.md`](https://github.com/akitaonrails/ai-memory/blob/main/docs/usage.md) explicitly states that "TTL outranks `pinned`" — a page marked as `pinned: true` with an elapsed `expires_at` timestamp is **still deleted** by the forget sweep.

This prioritization prevents accidental data retention through conflicting flags.

## Complete Page Lifecycle Diagram

| Stage | Trigger | State | User Visibility |
|-------|---------|-------|-----------------|
| Write | `memory_write_page` with `expires_at` | Active, stored | Full query visibility |
| Soft Expiry | Current time > `expires_at` | Filtered | Hidden from queries, file exists |
| Tombstone | `memory_forget_sweep` detects expiry | `is_latest=false` | Recoverable via history |
| Hard Delete | Grace period elapsed | Fully purged | Permanent removal |

## Summary

- **`memory_write_page`** stores `expires_at` as UTC timestamp in the pages table (`expires_at` column)
- **Read paths** in [`crates/ai-memory-store/src/reader.rs`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-store/src/reader.rs) filter with `expires_at IS NULL OR expires_at > :now`
- **`memory_forget_sweep`** runs daily, tombstones expired pages, then hard-deletes after grace period
- **TTL takes precedence** over the `pinned` flag — expired pinned pages are still removed

## Frequently Asked Questions

### What happens if I query a page after `expires_at` but before the sweep runs?

The page is **hidden from results** but recoverable. The SQL filter in the reader removes it from query output, yet the markdown file and database record remain intact until `memory_forget_sweep` executes its tombstoning and deletion phases.

### Can I extend or remove `expires_at` after writing a page?

Yes. Since `expires_at` is front-matter metadata, calling `memory_write_page` again with an updated or omitted `expires_at` overwrites the stored timestamp, effectively resetting or removing the TTL constraint.

### Does `memory_forget_sweep` only delete expired pages?

No. The sweep handles multiple retention policies including the M8 formula for version history. However, **TTL-expired pages receive immediate hard-deletion priority** alongside the standard tombstone-ancestry purge described in [`docs/ARCHITECTURE.md`](https://github.com/akitaonrails/ai-memory/blob/main/docs/ARCHITECTURE.md).

### What timestamp format does `expires_at` require?

RFC 3339 format with explicit timezone, such as `"2026-12-31T23:59:59Z"`. The system parses this via `jiff::Timestamp`, so offsets like `+00:00` are also accepted but UTC (`Z`) is recommended for clarity.