# Iris — Assistant Memory Substrate

Iris is the V1 assistant **memory and identity substrate**: it decides what the
platform is allowed to remember about a member, under what consent, in which
scope, and — critically — what it is allowed to _say back_ to them during a
recall. It serves every customer-facing surface that needs continuity (the
assistant shell, Tara/Veritas/Nyx/Arete/Nisaba/Metis, Living Scenes, and the
operator support workbench). It is the spine of every data-rights, consent,
suppression, and admin-inspection flow on the platform. This page sits among the
platform-substrate deep-dives in the V1 architecture set hubbed at
[../ARCHITECTURE.md](../ARCHITECTURE.md), alongside
[Sophia](./substrate-sophia.md), [Psyche](./substrate-psyche.md),
[Lilith](./substrate-lilith.md), [Isis](./substrate-isis.md), and
[Aje](./substrate-aje.md).

> **Naming caution.** This `Iris` is the _assistant memory substrate_ — it is a
> different thing from the `Iris` coding assistant that lives elsewhere in the
> monorepo. Nothing on this page concerns that tool.

> **Read this page for what is _shipping_ vs. _spec_.** The Iris core is
> overwhelmingly **real and in-repo**: the `MemoryEntry` contract, the
> deterministic recall pipeline with per-surface budgets, the consent ledger,
> the data-rights state machine, conflict resolution, multi-actor masking, and
> the admin-inspection state machine are all shipping code with tests. The two
> honest caveats are: (1) the relevance ranker's "semantic" factor is **Jaccard
> token overlap**, not embedding similarity (a documented approximation, not a
> stub); and (2) the customer memory-management UX at
> `apps/oshun/web/src/app/profile/memory/` exists but is **partial** per the
> completeness audit (edit/pause/forget is marked `partial`). FSRS spaced
> repetition is explicitly **not** an Iris feature — see the note at the end.

> **Canonical home (§13).** `Iris` is a cross-product substrate, so its
> canonical reference home is the domain space
> [`docs/domains/iris`](../../docs/domains/iris/deep-dive/architecture.md) and
> its code-linked entity catalog at
> [`systems/iris`](../../docs-center/systems/lib-iris.html). This page is V1's
> view — how the V1 platform composes `Iris`. The substrate itself is documented
> in full at its canonical home, which this page references rather than
> duplicates.

## Where Iris sits

- **Purpose:** assistant memory and identity boundaries — profile / session /
  scene / pose / notebook / crisis / operator-copilot / tenant memory, consent
  records, deletion and export (DSAR), privacy-aware suppression, conflict and
  freshness resolution, and policy-controlled admin inspection.
- **Canonical contracts:** `libs/contracts/src/iris/` — the `MemoryEntry` schema
  (`entry.ts`) and the `ContinuationToken` schema (`continuation.ts`), both
  exported through `@oshun/contracts`.
- **Substrate library:** `@oshun/memory-iris` at `libs/oshun/memory-iris`. Its
  `src/index.ts` re-exports 30+ modules including `adapter`,
  `assistant-identity`, `consent-ledger`, `data-rights`, `privacy-suppression`,
  `conflict-resolution`, `multi-actor`, `continuity`, `admin-inspection`,
  `recall`, `inference`, and `persistence`.
- **Assistant integration:** `@oshun/shell-assistant` (the customer assistant
  shell, handling intent classification and action routing across Tara, Veritas,
  Nyx, Arete, Nisaba, and Metis) bridges to Iris through
  `libs/oshun/shell-assistant/src/iris-memory-bridge.ts` and to the real-time
  runtime through `psyche-session-bridge.ts`.
- **Cross-cutting role:** Iris drives hand-off continuation state between
  desktop and mobile, gates admin inspection through policy, and underpins the
  data-export and data-deletion flows in profile/settings. See
  [Trust, Safety, and Privacy](./trust-safety-and-privacy.md) and
  [Data Architecture and Tenancy](./data-architecture-tenancy.md).

## The `MemoryEntry` contract

The canonical shape is the Zod `MemoryEntrySchema` at
`libs/contracts/src/iris/entry.ts:162` (`type MemoryEntry = z.infer<…>`). It is
designed so that every memory is **durable, auditable, reversible, and
scope-bounded**. The lifecycle is _immutable-revision-then-tombstone_: every
mutation mints a **new revision** that supersedes its predecessor (via
`revisionId` / `previousRevisionId`), and the only terminal lifecycle is
`tombstoned`. Nothing is overwritten in place; the chain is the record.

| Field                     | Type (Zod)                  | Meaning                                                               |
| ------------------------- | --------------------------- | --------------------------------------------------------------------- |
| `id`                      | UUID                        | Stable identity across all revisions of the memory.                   |
| `revisionId`              | UUID                        | This revision's id (new on every mutation).                           |
| `previousRevisionId`      | UUID \| null                | The revision this one supersedes; null on first write.                |
| `userId`                  | UUID                        | The member the memory is about.                                       |
| `tenantId`                | UUID \| null                | Owning tenant, or null for consumer-only memory.                      |
| `scope`                   | `MemoryScopeKey`            | The reachability boundary (discriminated union, below).               |
| `category`                | `MemoryCategory`            | One of ~28 taxonomy values (below).                                   |
| `body`                    | `string` (1–4000 chars)     | The remembered fact, in plain text.                                   |
| `confidence`              | number 0–1                  | How sure the system is of this memory.                                |
| `origin`                  | `MemoryOrigin`              | How it was captured (below).                                          |
| `lifecycle`               | `MemoryLifecycle`           | `draft \| active \| paused \| superseded \| tombstoned`.              |
| `consent[]`               | `ConsentEntry[]`            | Per-category consent attached to the entry.                           |
| `provenanceChain[]`       | `ProvenanceLink[]`          | Where it came from (session-turn, summary, promotion, import, …).     |
| `expiresAt`               | timestamp \| null           | Scheduled expiry, per scope retention.                                |
| `lastReferencedAt`        | timestamp \| null           | Drives the recency factor in ranking.                                 |
| `referenceCount`          | int ≥ 0                     | How often it has been recalled.                                       |
| `suppression`             | `MemorySuppression` \| null | A single active suppression marker (not an array).                    |
| `multiActor`              | `MultiActor` \| null        | Relationship-only reference to a third party (masked, not a profile). |
| `auditChain[]`            | `AuditEntry[]`              | Per-entry audit trail (created/updated/recalled/suppressed/…).        |
| `createdAt` / `updatedAt` | timestamp                   | Lifecycle timestamps.                                                 |

> **Doc correction (ARCHITECTURE.md–753 and features.md–1991).** The prose in
> the older docs describes several of these fields inaccurately. The bullets
> below give the real, code-grounded shapes; treat the code as ground truth
> where the docs and the code disagree.

### `body` is plain text, not a typed payload

Earlier docs (features.md–1976) describe `body` as a discriminated union of
`Fact | Preference | Goal | Boundary | Relationship | Schedule | LineageDeclaration | Sensitivity`.
**That is wrong.** In the real schema, `body` is `z.string().min(1).max(4000)`
(`entry.ts:170`) — a plain text payload capped at 4000 characters. The taxonomy
the docs were reaching for is a _separate_ field, `category`, modeled by
`MemoryCategorySchema` (`entry.ts:48–78`), a ~28-value enum:

```
fact · preference · goal · relationship · commitment · identity · spatial ·
pose_alignment · biometric · spiritual · medical · sexual · financial · legal ·
work · family · safety · physical_health · mental_health · substance_use ·
sexuality · gender_identity · religion_user_redacted · abuse_history ·
immigration_status · financial_distress · relationship_violence ·
legal_jeopardy · other
```

Nineteen of these are flagged as sensitive by the exported
`SENSITIVE_CATEGORIES` ReadonlySet (`entry.ts:187–211`), tested via
`isSensitiveCategory(category)`: `pose_alignment`, `biometric`, `spiritual`,
`medical`, `sexual`, `financial`, `legal`, `safety`, `physical_health`,
`mental_health`, `substance_use`, `sexuality`, `gender_identity`,
`religion_user_redacted`, `abuse_history`, `immigration_status`,
`financial_distress`, `relationship_violence`, and `legal_jeopardy`. This is far
more concrete than the prose list in features.md–1902 — the sensitive set is the
exact gate the recall pipeline keys on.

### Scope: eight kinds, not five

`MemoryScopeKeySchema` (`entry.ts:15–45`) is a `discriminatedUnion('kind', …)`
of **eight** kinds — not the five
(`profile | session | notebook | operator-copilot | tenant`) the V1 docs list at
ARCHITECTURE.md and features.md. The docs both _undercount_ and use _different
names_. The real union:

| Scope kind         | Carries                                                | Notes                                                                     |
| ------------------ | ------------------------------------------------------ | ------------------------------------------------------------------------- |
| `profile`          | `userId`                                               | Durable per-member identity/preferences.                                  |
| `session`          | `userId`, `sessionId`                                  | A single live session; can reach profile on recall.                       |
| `scene`            | `userId`, `sessionId`, `sceneId`, `roomId`             | V3 embodied / Living-Scene memory — **undocumented in the 5-scope list**. |
| `pose`             | `userId`, `sessionId`, `sceneId`, `poseId`, `avatarId` | Aggregate avatar/pose alignment — also undocumented here.                 |
| `notebook`         | `userId`, `notebookId`                                 | Notebook-linked recall; can reach profile.                                |
| `crisis`           | `userId`                                               | Crisis-frame scope — **undocumented in the 5-scope list**.                |
| `operator-copilot` | `userId`, `operatorId`                                 | Governed operator workspace recall.                                       |
| `tenant`           | `tenantId`, `userId`                                   | Institutional learner memory, never blended into consumer profile.        |

The `features.md` Iris section is also **internally inconsistent**: its
MemoryEntry subsection lists five scopes (features.md), but the
recall-resolution algorithm in the same document (features.md–2014) relies on
the `scene`/`crisis` scopes the five-item list omits. The code is the arbiter:
eight kinds, with `scene`/`pose`/`crisis` real and load-bearing.

> A separate, _adapter-level_ enum `IrisMemoryScope`
> (`libs/oshun/memory-iris/src/types.ts:45`) has **eleven** values —
> `assistant_profile`, `session`, `scene`, `pose`, `conversation`, `domain`,
> `cross_domain`, `notebook`, `operator_copilot`, `tenant`, `admin_review` — and
> uses underscored names (`assistant_profile`, not `profile`). The canonical
> contract (eight kinds) and the adapter enum (eleven) are two different views;
> don't conflate them.

### Origin, lifecycle, suppression, and multi-actor — the small fixes

These four fields are each described slightly wrong in the older docs:

- **Origin** (`MemoryOriginSchema`, `entry.ts:81–88`) =
  `user-stated | user-confirmed | model-inferred | operator-copilot | summarized-from-session | imported`.
  The doc's `promoted-from-session` (features.md) is named
  **`summarized-from-session`** in code, and the doc omits the real
  **`operator-copilot`** origin.
- **Lifecycle** (`MemoryLifecycleSchema`, `entry.ts:91–97`) =
  `draft | active | paused | superseded | tombstoned`. There is **no
  `summarized` lifecycle** (features.md invents one); the doc lists `summarized`
  and omits the real **`draft`**.
- **Suppression** (`MemorySuppressionSchema`, `entry.ts:124–129`) is a **single
  nullable object**, not an array (features.md–1988 says `suppression[]`). Its
  reason enum is
  `crisis-frame | user-pause | sensitive-category | tenant-quarantine` (the
  doc's `user-mute`/`tenant-policy-mute`/`category-revoked` are the stale names
  for `user-pause`/`tenant-quarantine`/`sensitive-category`). It also carries
  `startedAt`, a nullable `endsAt`, and free-text `notes`.
- **Multi-actor** (`MultiActorSchema`, `entry.ts:132–137`) is likewise a
  **single nullable object**, not an array (features.md–1991 says
  `multiActor[]`):
  `{ actorHandle, relationshipNote (≤500 chars), sensitiveInteraction }`. The
  comment in code is explicit — the note is _always relationship-only, never a
  third-party profile_.

### Consent on the entry vs. the ledger

The older docs describe a `ConsentRecord` with "prior state, new state, reason
code" (ARCHITECTURE.md). The shipping primitives are richer and differently
shaped:

- On the entry, `ConsentEntrySchema` (`entry.ts:100–105`) is
  `{ category, grantedAt, revokedAt (nullable), source }` where `source` is one
  of `inline-prompt | settings-toggle | dsar-import`.
- Separately, `@oshun/memory-iris/consent-ledger` maintains an **append-only
  ledger** of `IrisConsentEvent` records (grant / withdrawal / expiry / denial).
  A withdrawal never mutates the original grant — it appends a new event whose
  `supersedes` points back at the grant. Each event is bound by a deterministic
  fingerprint, `computeIrisConsentEventFingerprint(...)` (FNV-1a over stable
  JSON; `IRIS_CONSENT_EVENT_RECORD_VERSION = 1`), so if the underlying terms
  text later drifts, the fingerprint stops matching and the audit surfaces it.
  This append-only-with-fingerprinting ledger is real and was entirely
  undocumented in the V1 set.

## Memory recall resolution

Every recall — from the assistant, the shell, a notebook, a ritual resume, or an
admin surface — passes through the _same_ deterministic pipeline. The pipeline
is the `RecallPipeline` class at
`libs/oshun/memory-iris/src/recall/pipeline.ts:128`. Each surface calls
`resolve({ candidates, request })` and runs the same stages; only the
per-surface budget and rationale-visibility setting differ. **Recall is never
silent**: every surfaced entry carries a `surfaceRationale` and its
`provenanceChain`.

The stages, in order, exactly as `resolve()` runs them:

1. **Tenant boundary** — `matchesTenantBoundary` drops any entry whose tenant
   does not match the request's tenant (with consumer-only, null-tenant memory
   passing through). A miss increments `suppressionCounts['tenant-mismatch']`.
2. **Scope reachability** — `isReachableScope` enforces the hierarchy: a
   `session` recall can also reach `profile`; a `scene` recall can reach
   `scene → session → profile`; a `pose` recall reaches
   `pose → scene → session → profile`; a `notebook` recall reaches
   `notebook → profile`; a `crisis` recall reaches any of the member's own
   scopes _except_ operator-copilot; tenant entries are reachable only from
   `notebook`/`tenant` requests. A miss increments
   `suppressionCounts['scope-mismatch']`.
3. **Crisis gate** — when `request.crisisFrame === true`, candidates collapse to
   **safety-critical entries only** (`isSafetyCriticalEntry`, i.e. the `safety`
   category) and only when `safetyCriticalContext === true`; everything else is
   counted under `crisis-frame` and dropped (`pipeline.ts:162–165`). This
   matches features.md.
4. **Suppression + lifecycle gate** — entries with an _active_ suppression
   marker (respecting `endsAt`) are dropped under their reason; non-`active`
   lifecycle entries are dropped under `lifecycle-<state>`.
5. **Sensitive-category gate** — sensitive entries (or
   `multiActor.sensitiveInteraction`) require **both** an explicit per-category
   consent in `request.consentedCategories` **and** a _relevant_ sensitive
   context (`hasRelevantSensitiveContext`: e.g. `medical`/`mental_health` need
   `health`/`safety`/`crisis`; `gender_identity` needs `identity`/`safety`/
   `crisis`). Failures are split into `sensitive-category-without-consent` vs.
   `sensitive-category-outside-relevant-context`.
6. **Relevance ranker** — see below.
7. **Conflict / freshness dedup** — `dedupeByConflict` groups by a conflict key
   (`category :: actorHandle :: subject`); within a group, the **more recent
   `updatedAt` wins** — the "most-recent-wins" rule.
8. **Per-surface budget** — the result is sliced to the surface's `maxEntries`.
9. **Surface redaction + audit** — multi-actor refs are stripped on non-DSAR
   admin surfaces (`redactMultiActorForRecallSurface`), a `surfaceRationale`
   string is built per entry, and an audit envelope is emitted to the optional
   `auditSink`.

```mermaid
sequenceDiagram
    autonumber
    participant Ctx as Caller<br/>(assistant · domain · shell · admin)
    participant Iris as RecallPipeline
    participant Tenant as Tenant Boundary
    participant Scope as Scope Reachability
    participant Crisis as Crisis Gate
    participant Supp as Suppression / Lifecycle
    participant Sens as Sensitive-Category Gate
    participant Rank as Relevance Ranker
    participant Conf as Conflict / Freshness
    participant A as Audit

    Ctx->>Iris: resolve(candidates, request)
    Iris->>Tenant: enforce tenant boundary
    Tenant-->>Iris: tenant-clean set
    Iris->>Scope: reachable scopes from request scope
    Scope-->>Iris: scoped candidates
    Iris->>Crisis: crisisFrame? → safety-critical only
    Crisis-->>Iris: gated candidates
    Iris->>Supp: drop active suppression + non-active lifecycle
    Supp-->>Iris: candidates
    Iris->>Sens: per-category consent + relevant context
    Sens-->>Iris: consented candidates
    Iris->>Rank: score (recency · semantic · referenceCount · origin)
    Rank-->>Iris: ranked
    Iris->>Conf: dedupe by conflict key, most-recent-wins
    Conf-->>Iris: deduped, budget-capped
    Iris->>A: ingest audit envelope (scopeKey, returnedIds, suppressionCounts)
    Iris-->>Ctx: results + surfaceRationale + provenanceChain

    Note over Iris,A: Recall is never silent — every surfaced entry carries a rationale string.
```

### Per-surface budgets

`DEFAULT_BUDGETS` (`pipeline.ts:45–50`) caps how many entries each surface may
return and sets whether the rationale is shown to the caller:

| Surface     | `maxEntries` | `rationaleVisible` |
| ----------- | ------------ | ------------------ |
| `assistant` | 12           | false              |
| `shell`     | 3            | true\*/false       |
| `notebook`  | 999          | true               |
| `admin`     | 100          | true               |

(The `shell` budget ships with `rationaleVisible: false`; the assistant keeps
its rationale internal, while `notebook` and `admin` expose it.) These constants
match features.md–2031 closely. When a request names a surface that is not in
the budget map, the pipeline falls back to the `shell` budget — a deliberately
conservative default of 3 entries.

### The ranker and its honest approximation

The score is a four-factor weighted blend, `DEFAULT_WEIGHTS`
(`pipeline.ts:113–118`):

| Factor           | Weight | How it is computed                                                                                                                 |
| ---------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------- |
| `recency`        | 0.40   | Exponential decay on `lastReferencedAt` with a **30-day half-life**.                                                               |
| `semantic`       | 0.35   | `jaccardSemantic(entry.body, query)` — token-set Jaccard overlap.                                                                  |
| `referenceCount` | 0.15   | `min(1, referenceCount / 10)`.                                                                                                     |
| `origin`         | 0.10   | `originWeight`: user-stated 1.0 → user-confirmed 0.9 → summarized 0.75 → imported 0.7 → model-inferred 0.5 → operator-copilot 0.4. |

Both ARCHITECTURE.md ("score recency · semantic · referenceCount · origin") and
features.md–2021 describe this blend, and the code matches the four-factor
weighting. The **honest caveat neither doc flags**: the "semantic" factor is
_not_ embedding similarity — `jaccardSemantic` (`pipeline.ts:517`) lowercases,
splits on non-alphanumerics, keeps tokens longer than two characters, and
returns `intersection / union` of the two token sets. It is a real,
deterministic, documented approximation (cheap, explainable, no model
dependency). The pipeline is built to accept an injected
`semantic(entryText, query) => number`, so a true embedding ranker can be
supplied later without touching the gating logic. It is _not_ a stub standing in
for a result it didn't compute.

### The audit envelope

The docs describe the recall audit only as "suppressed entries (count only)."
The real shape is concrete: `RecallAuditEnvelope` (`pipeline.ts:84–89`) carries
`scopeKey`, `returnedIds[]`, `evaluatedCount`, and a
`suppressionCounts: Record<string, number>` keyed by the _reason_ an entry was
dropped — `tenant-mismatch`, `scope-mismatch`, `crisis-frame`, the suppression
reason, `lifecycle-<state>`, `sensitive-category-without-consent`, and
`sensitive-category-outside-relevant-context`. When an `auditSink` is wired, the
pipeline emits a canonical `IngestCanonicalAuditEventRequest` with action
`memory.recall.resolved`, `policyId: 'iris-memory-recall-v1'`, and severity that
escalates to `warning` whenever anything was suppressed — so the audit log can
distinguish a clean recall from one that quietly dropped sensitive material.

## Scope retention and session→profile promotion

`scope-hierarchy.ts` holds the cross-scope retention guarantees as pure,
testable constants (`getIrisScopeRetentionEnvelope`):

| Scope                      | Raw retention                               | Summary retention                          | Expiry action |
| -------------------------- | ------------------------------------------- | ------------------------------------------ | ------------- |
| `assistant_profile`        | until user/account deletion                 | —                                          | review        |
| `session` / `conversation` | `IRIS_SESSION_RAW_RETENTION_DAYS = 30`      | `IRIS_SESSION_SUMMARY_RETENTION_DAYS = 90` | summarize     |
| `scene`                    | 14                                          | 90                                         | summarize     |
| `pose`                     | 7                                           | 30                                         | summarize     |
| `operator_copilot`         | `IRIS_OPERATOR_COPILOT_RETENTION_DAYS = 14` | —                                          | review        |
| `tenant`                   | `IRIS_TENANT_MEMORY_RETENTION_DAYS = 365`   | —                                          | review        |
| `notebook`                 | until notebook deleted                      | —                                          | delete        |

Session memory does **not** silently leak into profile memory. Promotion is
governed by `evaluateIrisSessionProfilePromotion`: an _explicit_ promotion
intent is allowed immediately, but an _implicit_ promotion is only allowed once
a fact recurs at least `IRIS_SESSION_PROFILE_PROMOTION_THRESHOLD = 3` times —
otherwise it is blocked with `repeated-occurrence-threshold-not-met` (or
`implicit-promotion-blocked`). The assistant bridge promotes only items the
session marked `promotionCandidate=true`, and only at session close, through the
adapter's consent-checked `remember` path.

## Conflict resolution — provenance beats recency

When two memories collide on the same conflict key, recency is _not_ the only
rule. `conflict-resolution.ts` classifies each side's source
(`sourceKindForRecord`: `explicit_user`, `user_confirmed`, `model_inferred`,
`admin_copilot_inferred`) and applies a precedence ladder in `chooseRule`:

- **`explicit-user-trumps-inferred`** — a user-stated memory overrides an
  inferred one regardless of which is newer.
- **`user-confirmed-trumps-inferred`** — a user-confirmed memory likewise
  outranks inference, in either direction.
- **`admin-copilot-never-trumps-consumer-stated`** — an operator-copilot
  inference can _never_ override what the member stated or confirmed; the
  existing memory is kept.
- **`most-recent-wins`** — the fallback when both sides share the same source
  tier.

This is the concrete realization of the "user-stated > inferred" and
"most-recent-wins" rules the docs describe in prose, and it is enforced both at
write time (`resolveIrisMemoryWriteConflict`) and inside the recall dedup step.

## Data rights (DSAR): delete, export, access

`data-rights.ts` defines the long-lived lifecycle record for every
member-initiated data-rights request.
`IRIS_DATA_RIGHTS_REQUEST_KINDS = ['delete', 'export', 'access']`
(`data-rights.ts:63`). Each request carries a status machine
(`submitted → verified → in-progress → completed | failed | cancelled | expired | appealed`),
a `verifyBy` deadline (default 7 days), a statutory `deadlineAt` (default 30
days — `IRIS_DATA_RIGHTS_DEFAULT_DEADLINE_MS`), an **append-only** `auditTrail`
validated to be chronologically non-decreasing, and a `resultFingerprint` that
binds the produced artifact (export-file hash or deleted-id-set hash) to the
request so an operator can later prove the right data was returned or deleted.

The three payloads are concrete, beyond the docs' bare "export/delete" bullets:

- **`delete`** — `mode: 'soft' | 'hard'`, a list of scopes/categories,
  `purgeConsentLedger` (also purge the consent-ledger events for those
  categories), and a `retentionGraceMs` window within which a _soft_ delete may
  be re-activated. The validator enforces that a `hard` delete must have a zero
  grace window.
- **`export`** — scopes + `format` + include flags (`includeConsents`,
  `includeOptOuts`, `includeNotebooks`, `includeSessionMemory`, plus optional
  `includeSceneMemory` / `includePoseMemory`) + `deliveryChannel` + `encrypted`.
  `buildIrisDataRightsMemoryExportBundle` assembles scene/pose payloads and
  refuses to ship a scene/pose scope unless its include flag is set.
- **`access`** — a read-only "show me what you have on me" request that never
  mutates data, with an `includeOperatorActions` flag.

`expireUnverifiedIrisDataRightsRequest` auto-expires any `submitted` request
whose `verifyBy` has elapsed (idempotent), and `toIrisDataRightsReceipt`
produces the member-facing receipt with `isPastDeadline` /
`msRemainingToDeadline`.

## Per-scope governance policy

Beyond retention, `memory-model.ts` carries a rich per-scope **governance**
record the V1 docs never surfaced. Each `IrisMemoryScope` declares
`requiredConsents`, `optOutCategories`, `canonicalTiers`, `adminReviewable`,
`requiresGovernanceReview`, and an `allowedConsumers` allowlist. For example:

- `pose` memory requires `sensitive_data` consent on top of
  `data_processing`/`data_storage`, sets `requiresGovernanceReview: true`, and
  limits consumers to `assistant`, `tara`, `studio`, `admin`.
- `operator_copilot` requires _no_ member consent (it is operator-governed), has
  `requiresGovernanceReview: true`, and is consumable only by `admin` and
  `support`.
- `tenant` (institutional Metis learner-state) is explicitly _"never blended
  into consumer profile memory"_, requires governance review, and is bounded to
  one tenant.

This allowlist is the policy that decides which domain surface may even _ask_
Iris for a given scope — the recall pipeline's reachability check enforces the
graph, and this metadata enforces the consumer boundary.

## Admin inspection — a gated state machine

When an operator needs to _look at_ a member's memory (DSAR fulfillment, an
incident, a routine review), the access is mediated by the
`InspectionStateMachine` at
`libs/oshun/memory-iris/src/admin-inspection/state-machine.ts`. The states and
allowed transitions are an **exact match** to features.md–2153:

| From                  | To                                                            |
| --------------------- | ------------------------------------------------------------- |
| `requested`           | `dsar-fulfillment` · `incident-escalation` · `routine-review` |
| `dsar-fulfillment`    | `granted` · `denied`                                          |
| `incident-escalation` | `granted` · `denied`                                          |
| `routine-review`      | `granted` · `denied`                                          |
| `granted`             | `closed`                                                      |
| `denied`              | (terminal)                                                    |
| `closed`              | (terminal)                                                    |

The machine is **fail-closed at the grant boundary**: every `→ granted`
transition runs the `PolicyGate` first, and a refusal appends a `denied` event
(with a reason code such as `consent-not-current`, `scope-out-of-bounds`,
`cross-tenant`, or `no-legitimate-interest`) rather than granting. The
`defaultPolicyGate` encodes real rules — e.g. sensitive targets require a
current consent snapshot and _cannot_ be granted to `support`/`admin` roles,
only `t&s` with a signed incident ref or `legal` on a DSAR. Every transition is
audited as a canonical `IngestCanonicalAuditEventRequest`
(`policyId: 'iris.admin_inspection.state_machine.v1'`), and a sensitive
`granted → closed` transition queues a **user notice** within a 72-hour window
(`userNoticeWindowMs`, subject to an `investigationCarveout`).
`replayGrantedSnapshot` can reconstruct _exactly_ the `MemoryEntry` set the
operator saw — multi-actor data redacted unless the inspection was a DSAR.

## Cross-device continuity

Continuity lives in two real places. The canonical wire contract is
`ContinuationTokenSchema` (`libs/contracts/src/iris/continuation.ts:52`, with
`type ContinuationToken` at line 111, §10.13). Critically, the token carries
**references and posture only — never content**:

```json
{
  "tokenId": "…",
  "userId": "…",
  "scopeKey": "session:…",
  "deviceId": "…",
  "surfaceContext": { "surface": "psyche", "sessionId": "…", "tenantId": null },
  "anchorRef": { "surface": "psyche", "anchorId": "…", "cursor": "00:14:32" },
  "posture": "listening",
  "sensitiveCategories": [],
  "crossDeviceConsentId": null,
  "idempotencyKey": "…",
  "lastUpdatedAt": "…"
}
```

`surface` is one of `tara | psyche | living-scene | nisaba | metis | shell`;
`posture` is one of
`reading | listening | co-watching | practicing | studying | reviewing | paused`;
`anchorRef.cursor` is a locator _inside_ the anchor (seconds into audio, fold
index in a scene, page in a Nisaba edition). The schema enforces two invariants
via `superRefine`: `anchorRef.surface` must equal `surfaceContext.surface`, and
**`crossDeviceConsentId` is required whenever `sensitiveCategories` is
non-empty** — sensitive material may not cross devices without a fresh
cross-device consent on top of normal consent. (Note the real field set differs
slightly from the V1 docs' sketch
`{userId, scopeKey, surfaceContext, anchorRef, posture, lastUpdatedAt}`: the
token also carries `tokenId`, `deviceId`, `sensitiveCategories`,
`crossDeviceConsentId`, and `idempotencyKey`.) The runtime side of continuity —
state assembly and the desktop↔mobile hand-off — lives in
`libs/oshun/memory-iris/src/continuity/` and `mobile-handoff.ts`.

## Assistant integration

`@oshun/shell-assistant` is the customer assistant shell: it classifies intent
and routes actions across Tara, Veritas, Nyx, Arete, Nisaba, and Metis (see
`action-router.ts`). It reaches Iris through `iris-memory-bridge.ts`
(V1-IRIS-008), which hydrates the runtime `AssistantMemoryContext` from the
`IrisContinuityState` Iris reports and maintains a per-session append-only
`IrisConversationHistory` and an ephemeral `IrisSessionMemory`. It also routes
every durable write through the adapter's consent-enforcing `remember` path —
_never_ around it. A missing consent therefore yields a warning-bearing
suppressed outcome rather than a silent swallow. It reaches the real-time
runtime through `psyche-session-bridge.ts`. See
[Psyche — Real-Time Runtime Substrate](./substrate-psyche.md).

## Customer memory UX — partial, honestly

The member-facing memory-management surface exists at
`apps/oshun/web/src/app/profile/memory/` (`page.tsx`,
`ProfileMemoryControls.tsx`, `memory-state.ts`). Per the completeness audit it
is **partial** — the _memory-edit / pause / forget_ flow is marked `partial`,
not shipped-complete. The substrate beneath it (revisioned entries, suppression
markers, DSAR delete/export, consent ledger) is real and ready; the gap is the
unfinished consumer UI, and this page records that honestly rather than implying
a finished experience.

## What Iris is _not_: FSRS

The substrate audit asked whether Iris uses **FSRS** spaced repetition for
memory decay. It does not. FSRS v4 is real, but it lives in **mnemosyne**, not
Iris: `libs/mnemosyne/core/src/memory-science.ts` exports `FSRSParameters`,
`FSRSReviewResult`, `FSRS_DEFAULT_PARAMETERS`, and `calculateRetention`. Iris
decay is **usage-weighted recall scoring** — the `DEFAULT_WEIGHTS` blend above
(30-day recency half-life, Jaccard semantic, reference count, origin). The only
`stability`/`fsrs` strings anywhere in `@oshun/memory-iris` are unrelated (sort
stability, a status literal). The Iris features doc itself never claims FSRS, so
it is accurate by omission — but the cross-reference is worth making explicit so
the two substrates are not conflated. Metis's learner scheduling, which _does_
use FSRS, is described under [Customer-Facing Domains](./customer-domains.md).

## Related

- [Trust, Safety, and Privacy](./trust-safety-and-privacy.md)
- [Data Architecture and Tenancy](./data-architecture-tenancy.md)
- [Security, Privacy, and Compliance](./security-privacy-compliance.md)
- [Psyche — Real-Time Runtime Substrate](./substrate-psyche.md)
- [Sophia — Grounding Substrate](./substrate-sophia.md)
- [Customer-Facing Domains](./customer-domains.md)
- [Subsystem Glossary](./glossary.md)
- [`V1/features.md` § Memory Entry Schema, Recall Mechanics, and Workflows](../features.md#memory-entry-schema-recall-mechanics-and-workflows)
- [Hub: V1 Architecture](../ARCHITECTURE.md)
