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
Irisis the assistant memory substrate — it is a different thing from theIriscoding 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
MemoryEntrycontract, 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 atapps/oshun/web/src/app/profile/memory/exists but is partial per the completeness audit (edit/pause/forget is markedpartial). FSRS spaced repetition is explicitly not an Iris feature — see the note at the end.
Canonical home (§13).
Irisis a cross-product substrate, so its canonical reference home is the domain spacedocs/domains/irisand its code-linked entity catalog atsystems/iris. This page is V1's view — how the V1 platform composesIris. 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/— theMemoryEntryschema (entry.ts) and theContinuationTokenschema (continuation.ts), both exported through@oshun/contracts. - Substrate library:
@oshun/memory-irisatlibs/oshun/memory-iris. Itssrc/index.tsre-exports 30+ modules includingadapter,assistant-identity,consent-ledger,data-rights,privacy-suppression,conflict-resolution,multi-actor,continuity,admin-inspection,recall,inference, andpersistence. - 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 throughlibs/oshun/shell-assistant/src/iris-memory-bridge.tsand to the real-time runtime throughpsyche-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:
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, notprofile). 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'spromoted-from-session(features.md) is namedsummarized-from-sessionin code, and the doc omits the realoperator-copilotorigin. - Lifecycle (
MemoryLifecycleSchema,entry.ts:91–97) =draft | active | paused | superseded | tombstoned. There is nosummarizedlifecycle (features.md invents one); the doc listssummarizedand omits the realdraft. - Suppression (
MemorySuppressionSchema,entry.ts:124–129) is a single nullable object, not an array (features.md–1988 sayssuppression[]). Its reason enum iscrisis-frame | user-pause | sensitive-category | tenant-quarantine(the doc'suser-mute/tenant-policy-mute/category-revokedare the stale names foruser-pause/tenant-quarantine/sensitive-category). It also carriesstartedAt, a nullableendsAt, and free-textnotes. - Multi-actor (
MultiActorSchema,entry.ts:132–137) is likewise a single nullable object, not an array (features.md–1991 saysmultiActor[]):{ 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 }wheresourceis one ofinline-prompt | settings-toggle | dsar-import. - Separately,
@oshun/memory-iris/consent-ledgermaintains an append-only ledger ofIrisConsentEventrecords (grant / withdrawal / expiry / denial). A withdrawal never mutates the original grant — it appends a new event whosesupersedespoints 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:
- Tenant boundary —
matchesTenantBoundarydrops any entry whose tenant does not match the request's tenant (with consumer-only, null-tenant memory passing through). A miss incrementssuppressionCounts['tenant-mismatch']. - Scope reachability —
isReachableScopeenforces the hierarchy: asessionrecall can also reachprofile; ascenerecall can reachscene → session → profile; aposerecall reachespose → scene → session → profile; anotebookrecall reachesnotebook → profile; acrisisrecall reaches any of the member's own scopes except operator-copilot; tenant entries are reachable only fromnotebook/tenantrequests. A miss incrementssuppressionCounts['scope-mismatch']. - Crisis gate — when
request.crisisFrame === true, candidates collapse to safety-critical entries only (isSafetyCriticalEntry, i.e. thesafetycategory) and only whensafetyCriticalContext === true; everything else is counted undercrisis-frameand dropped (pipeline.ts:162–165). This matches features.md. - Suppression + lifecycle gate — entries with an active suppression
marker (respecting
endsAt) are dropped under their reason; non-activelifecycle entries are dropped underlifecycle-<state>. - Sensitive-category gate — sensitive entries (or
multiActor.sensitiveInteraction) require both an explicit per-category consent inrequest.consentedCategoriesand a relevant sensitive context (hasRelevantSensitiveContext: e.g.medical/mental_healthneedhealth/safety/crisis;gender_identityneedsidentity/safety/crisis). Failures are split intosensitive-category-without-consentvs.sensitive-category-outside-relevant-context. - Relevance ranker — see below.
- Conflict / freshness dedup —
dedupeByConflictgroups by a conflict key (category :: actorHandle :: subject); within a group, the more recentupdatedAtwins — the "most-recent-wins" rule. - Per-surface budget — the result is sliced to the surface's
maxEntries. - Surface redaction + audit — multi-actor refs are stripped on non-DSAR
admin surfaces (
redactMultiActorForRecallSurface), asurfaceRationalestring is built per entry, and an audit envelope is emitted to the optionalauditSink.
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 aretentionGraceMswindow within which a soft delete may be re-activated. The validator enforces that aharddelete must have a zero grace window.export— scopes +format+ include flags (includeConsents,includeOptOuts,includeNotebooks,includeSessionMemory, plus optionalincludeSceneMemory/includePoseMemory) +deliveryChannel+encrypted.buildIrisDataRightsMemoryExportBundleassembles 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 anincludeOperatorActionsflag.
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:
posememory requiressensitive_dataconsent on top ofdata_processing/data_storage, setsrequiresGovernanceReview: true, and limits consumers toassistant,tara,studio,admin.operator_copilotrequires no member consent (it is operator-governed), hasrequiresGovernanceReview: true, and is consumable only byadminandsupport.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:
{
"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.