Oshun Platform · Architecture

Iris — Assistant Memory Substrate

A focused page within the Oshun Platform Architecture documentation. The full map and every sibling page live in the Architecture hub.

13sections17 minread1diagram6tables

On this page

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, alongside Sophia, Psyche, Lilith, Isis, and Aje.

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 and its code-linked entity catalog at systems/iris. 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 and Data Architecture and Tenancy.

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:

text
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 boundarymatchesTenantBoundary 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 reachabilityisReachableScope 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 dedupdedupeByConflict 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.
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:

  • deletemode: '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.

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.