---
path: /profile/memory
surface: customer
domain: account
auth: signed-in
source: apps/oshun/web/src/app/profile/memory/page.tsx
status: walked
last_walked:
  '2026-05-29 automated runtime walk (Playwright headless) — render, /v1 data
  (2xx), console/page-errors, expected content, screenshot verified; live
  screen-reader, touch, offline, and telemetry-delivery checks pending a manual
  AT pass. Evidence: WALKTHROUGH/results/runtime-sweep-2026-05-29.md; content
  re-verified 2026-06-03 against current source; real Iris empty + populated BFF
  hydration re-verified 2026-06-25 by memory-edit-pause-forget.spec.ts;
  persisted pause + assistant dock memory-off handoff re-verified 2026-06-27;
  anonymous redirect and offline cached/read write-block path re-verified
  2026-06-27; transient BFF review failure + retry re-verified 2026-06-30; real
  Iris single-fact hard-delete through /v1/iris/adapter/memory/:memoryId plus
  review read-back absence re-verified 2026-06-30; cross-tab edit-conflict
  keep-remote and overwrite resolution re-verified 2026-06-30; synced Iris
  export receipt through /v1/iris/adapter/export re-verified 2026-06-30'
---

# Profile · Memory Controls

## Purpose

OSHUN assistant memory controls — review, edit, export, pause, and delete what
the assistant remembers. Thin server component that mounts the
`ProfileMemoryControls` client composition.

## Entry points

- `/profile` sub-section deep link (highlightPath = memory)
- Profile settings panel "Memory" row → `/profile/memory`
- Privacy-related links from `/legal/privacy` (assistant memory section) —
  verify
- Direct URL / bookmark — yes (auth required)
- Onboarding "Memory" step (`/onboarding?step=memory`) hand-off — verify

## Layout regions

`page.tsx` is `<ProfileMemoryControls />` with no other chrome from the route.
The component composition is in
`apps/oshun/web/src/app/profile/memory/ProfileMemoryControls.tsx` — a
`'use client'` view that starts from the honest empty local state, adopts any
cached local control state after hydration, then replaces it with the member's
real Iris review from the BFF when `/v1/iris/adapter/review` returns records.
The pause state is also hydrated from `/v1/iris/adapter/pause?role=settings` and
profile pause/resume writes are persisted back through that BFF endpoint.
Regions:

- **Header** — `<h1>` "What OSHUN remembers", the off-the-record pause switch,
  and Export JSON
- **Alert banners** — off-the-record banner, full-clear 7-day grace banner, and
  transient Iris BFF read-unavailable retry banner (conditional)
- **Status grid** — State / Active facts / Audit events / Tombstones metrics
- **Workspace** — left: the paginated **Memory facts** table with category /
  scope filters and scope tabs; right: the per-fact **inspector** (detail,
  provenance, recent recalls, edit, scope-change, fact controls, step-up, hard
  delete, full clear)
- **Controls grid** — Category controls, Scope controls, Export bundle
- **Audit chain** + footer toast / reset

## States

- [x] **Initial hydration / loading** — there is no skeleton; the route renders
      the deterministic empty state first, then shows the sync notice once real
      Iris records replace it
- [x] **Empty** — no stored memories; ledger row
      `data-profile-memory-empty-state` says "No remembered facts yet." and the
      inspector shows `data-profile-memory-empty-inspector`
- [x] **Populated** — list of real Iris or cached memory entries (scope,
      category, origin, content, source, timestamp)
- [x] **Paused** — memory capture toggle off; existing entries still visible
- [x] **Editing** — selected entry exposes label/content edit controls
- [x] **Exporting / export ready** — synced Iris exports first request a
      server-side export receipt from `/v1/iris/adapter/export?role=settings`,
      then show a non-secret receipt plus the inline signed JSON preview and
      download link. Local-only cached exports show the same signed bundle with
      a local-only receipt note.
- [x] **Deleting (single)** — destructive confirmation + step-up phrase
- [x] **Deleting (all)** — full-clear confirmation + step-up + 7-day grace
      banner
- [x] **Error (recoverable)** — a failed Iris review/consent hydration keeps
      cached/local memory rows visible, exposes `data-profile-memory-bff-status`
      with retry copy, and the Retry Iris read action re-runs the real BFF
      hydration path
- [x] **Anonymous user** — middleware redirects direct `/profile/memory` visits
      to `/welcome?redirect=/profile/memory`; the memory surface does not mount
- [x] **Offline** — cached memory entries remain readable; signed-in BFF-backed
      pause writes fail closed with "Off-the-record mode could not sync. Try
      again.", the switch remains off, and no pause banner is shown

## Interactions

All interactions live inside `ProfileMemoryControls` (a single `'use client'`
view). Heading: `<h1>` "What OSHUN remembers". The real affordances (with their
`data-profile-memory-*` test ids):

### Header

- [x] **Off the record / Resume memory** (`role="switch"`,
      `data-profile-memory-pause`) — toggles capture-pause; raises an "Off the
      record" `role="alert"` banner (`data-profile-memory-pause-banner`) with
      its own Resume button. For signed-in real Iris users this writes
      `PATCH /v1/iris/adapter/pause?role=settings`, exposes
      `data-profile-memory-pause-sync`, and notifies the shell assistant dock so
      its memory disclosure switches to `off`.
- [x] **Export JSON** (`data-profile-memory-export`) — see Export panel below

### Status grid

- [x] **Status metrics** (non-interactive) — State, Active facts, Audit events,
      Tombstones

### Memory facts table

- [x] **Category filter** / **Scope filter** (`<select>`s) — filter the fact
      rows by category / scope
- [x] **Scope tabs** (`role="tablist"`, `data-profile-memory-scope-tabs`) — "All
      scopes" + one tab per scope present, each with a live count
- [x] **Fact rows** (`data-profile-memory-fact-row`) — paginated table (Fact /
      Category / Scope / Origin / Last used / Status / Actions); selecting a row
      drives the inspector; per-row **Edit / Clear / Opt out** icon buttons
- [x] **Empty ledger** (`data-profile-memory-empty-state`) — rendered when
      `pagedFacts.length === 0`; no fabricated member names or demo facts
- [x] **Pagination** (`data-profile-memory-pagination`) — Previous / Next over
      `FORGET_PAGE_SIZE` (10) rows per page

### Inspector (selected fact)

- [x] **Empty inspector** (`data-profile-memory-empty-inspector`) — rendered
      when no selected fact exists
- [x] **Detail** — body, scope / category / origin / suppression / source /
      downstream-use metadata, and a "View audit chain →" jump link
- [x] **Provenance chain** (`data-profile-memory-provenance-chain`) — created +
      each revision + "Edited from" predecessor
- [x] **Recent recalls** (`data-profile-memory-recent-recalls`) — last surfaces
      this fact was recalled on
- [x] **Edit fact** (`data-profile-memory-save-edit`) — label + content inputs,
      optimistic-concurrency check; on conflict an edit-conflict panel
      (`data-profile-memory-edit-conflict`) offers **Keep remote version** /
      **Save my edit as the next revision**
- [x] **Move to a different scope** (`data-profile-memory-scope-change-panel`) —
      target-scope `<select>` + **Move scope**; rejects tenant→consumer
      promotion
- [x] **Fact controls** — **Opt out this fact** / **Clear this fact**. When the
      selected row came from a synced Iris review, **Clear this fact** first
      calls `DELETE /v1/iris/adapter/memory/:memoryId?role=settings&mode=soft`;
      local cached rows still use the local control-state reducer.
- [x] **Step-up authentication** (`data-profile-memory-step-up-panel`) —
      challenge-phrase input gating destructive flows; shows active/missing
      token status
- [x] **Hard delete** (`data-profile-memory-hard-delete`) — irreversible delete,
      requires the hard-delete confirmation phrase + a valid step-up token. For
      synced Iris rows, the page first calls
      `DELETE /v1/iris/adapter/memory/:memoryId?role=settings&mode=hard`; the
      real BFF review read-back no longer returns that memory.
- [x] **Forget everything (7-day grace)**
      (`data-profile-memory-full-clear-panel`) — full-clear confirmation
      phrase + step-up; schedules a clear with a 7-day grace banner
      (`data-profile-memory-grace-banner`) carrying a **Cancel clear** action

### Category / scope controls

- [x] **Category controls** (`data-profile-memory-category-controls`) — per
      category: **Opt out** / **Clear** (clear requires step-up)
- [x] **Scope controls** (`data-profile-memory-scope-controls`) — per scope:
      **Opt out** / **Clear** (clear requires step-up)

### Export panel

- [x] **Export bundle** (`data-profile-memory-export-panel`) — optional **DSAR
      request ID** input flags the bundle as DSAR fulfilment; **Build export /
      Sign DSAR bundle** produces a signed JSON (facts, consents, opt-outs,
      tombstones, downstream uses, audit chain) with a download link + preview.
      When the page is synced from real Iris, the same action first posts to
      `/v1/iris/adapter/export?role=settings` and renders
      `data-profile-memory-export-receipt` with the export id, memory count, and
      checksum.
- [x] **Audit chain** (`#audit-chain`, `data-profile-memory-audit-panel`) — last
      memory mutations, appended and signed in the export bundle
- [x] **Reset local memory controls** (footer) — re-seeds the local state

## Data & contracts

- **Reads**: hybrid client cache + real BFF hydration. `ProfileMemoryControls`
  starts with `buildInitialProfileMemoryState()` from `@oshun/memory-iris`,
  adopts `localStorage[PROFILE_MEMORY_STORAGE_KEY]` after hydration, then calls
  `fetchRealProfileMemoryStateResult(realUserId)` once `useOshunProfileStore()`
  has a user id. That helper reads
  `/v1/iris/adapter/review?role=settings&pageSize=100` and
  `/v1/iris/adapter/consents?role=settings` through the shared `api` client.
  Non-empty BFF reviews replace the local state and show "Memory controls synced
  with your Iris record"; empty BFF reviews return `{ status: 'empty' }` so a
  member's cached local facts are not erased by an empty read. Unavailable BFF
  reads return `{ status: 'unavailable' }`, leave cached controls visible, and
  render the retry banner instead of silently falling back.
- **Pause read/write**: `fetchRealProfileMemoryPauseState('settings')` hydrates
  the persisted pause record even when review is empty. The signed-in pause
  toggle and banner Resume action call
  `PATCH /v1/iris/adapter/pause?role=settings`; the response is mirrored into
  local control state and dispatches `oshun:iris-memory-pause-changed` for the
  shell assistant dock.
- **Other writes**: single-fact clear and hard-delete now call the real Iris
  delete endpoint first when the page is synced from BFF review, then mirror the
  existing local control-state mutation for the current view. Synced exports
  call the real Iris export endpoint and render the returned receipt before the
  local signed bundle is exposed. Edit / opt-out / scope change / category clear
  / scope clear / full-clear still return a new `IrisUserMemoryControlState`
  from a `@oshun/memory-iris` pure function and persist to `localStorage`; those
  mutations do not yet POST back to Iris.
- **Realtime**: same-shell pause changes update the assistant dock via the
  pause-changed event; focus/visibility refreshes read the persisted BFF pause.
  Other server-persisted memory edits do not stream in real time, but open
  `/profile/memory` tabs listen for same-origin `localStorage` changes so a
  local edit in one tab can refresh another tab and trigger the optimistic
  edit-conflict panel instead of silently resetting a dirty draft.
- **Caching**: `localStorage` (`PROFILE_MEMORY_STORAGE_KEY`) for the control
  state; no SWR, no server cache. The offline route-level E2E keeps seeded
  cached rows visible while `navigator.onLine === false` and asserts that the
  signed-in pause mutation is blocked rather than queued locally when the BFF
  cannot be reached.
- **Auth/role check**: signed-in; middleware enforces
- **Metadata**: `title: 'Memory Controls'`,
  `description: 'Review, edit, export, pause, and delete OSHUN assistant memory.'`,
  `robots: { index: false, follow: false }`,
  `alternates.canonical: '/profile/memory'`

## Cross-references

- Parent: [`profile.md`](./profile.md)
- Sibling: [`profile-safety.md`](./profile-safety.md)
- Onboarding memory step:
  [`../01-onboarding/onboarding.md`](../01-onboarding/onboarding.md)
- Legal: [`../00-public/legal-privacy.md`](../00-public/legal-privacy.md)
- Component sources:
  - `apps/oshun/web/src/app/profile/memory/ProfileMemoryControls.tsx`
  - `apps/oshun/web/src/app/profile/memory/memory-state.ts`
- E2E sources:
  - `apps/oshun/web/e2e/memory-edit-pause-forget.spec.ts`
  - `apps/oshun/web/e2e/profile-memory-controls.spec.ts`

## Open questions / known gaps

- [x] Single-fact clear / hard-delete are wired to the real Iris delete endpoint
      when the page is synced from BFF review;
      `memory-edit-pause-forget.spec.ts` hard-deletes a remembered profile fact
      from `/profile/memory` and proves `/v1/iris/adapter/review` no longer
      returns the deleted content.
- [ ] Writes remain local-only for edit, opt-out, scope change, category/scope
      clear, and full-clear; pause/resume, single-fact delete, and synced export
      receipt creation are the controls currently wired to BFF Iris mutation
      endpoints.
- [x] Failed BFF hydration keeps cached/local state, exposes a visible
      retry/status banner, and retries through the real BFF hydration path in
      `memory-edit-pause-forget.spec.ts`.
- [x] Confirm whether memory pause affects ongoing assistant sessions in real
      time: same-shell pause/resume dispatches update the assistant dock to
      memory scope `off`, and focus/visibility refreshes re-read the persisted
      BFF pause endpoint.
- [ ] Confirm the DSAR-backed export/delete path for this exact UI; synced
      export now records an Iris export receipt, but the optional DSAR request
      id is still only signed into the local JSON bundle and is not yet
      persisted as an `IrisDataRightsRequest`.
- [x] Anonymous redirect and offline cached/write-blocked behavior are covered
      at route level in `memory-edit-pause-forget.spec.ts`.
