Oshun Platform · Features

Iris Memory and Identity

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

16sections23 minread5tables

On this page

Iris is the subsystem that decides what the assistant remembers, for how long, under what conditions, and who else can ever see it. It owns assistant identity, the memory scope hierarchy, retention and decay, conflict resolution, privacy-aware suppression, cross-device continuity, and the operator-inspection regime. Iris serves every customer-facing surface in V1 — the Assistant Experience, Tara, Arete, Veritas, Nyx, Nisaba, and Metis — plus the governed admin path described in Review, Compliance, and Trust & Safety and Privacy, Consent, Data Portability, and User Controls. It sits between the Psyche Real-Time Runtime, which produces the conversational turns, and the durable governance substrate, which audits every recall, write, and inspection. Backlog and acceptance criteria live under §10 of V1/TODOS.md; the contracts referenced here are shipping Zod schemas in libs/contracts/src/iris/, and the runtime lives in the @oshun/memory-iris package (libs/oshun/memory-iris/).

Reality note. This area is overwhelmingly shipped, not aspirational: the MemoryEntry contract, the deterministic RecallPipeline, the append-only consent ledger, the data-rights lifecycle, the conflict resolver, multi-actor masking, and the admin-inspection state machine all exist as real, tested modules. Where the prose of the original spec diverges from the code (scope names, the body field, lifecycle values, FSRS), this page follows the code and calls the divergence out honestly. Iris memory decay is usage-weighted, not FSRS — FSRS v4 is a separate Mnemosyne concern (libs/mnemosyne/core/src/memory-science.ts) and is not an Iris feature.


Memory Scope Hierarchy#

A scope is the boundary that determines which contexts may read or write a given memory. Iris models scope at two layers, and the two layers do not have identical vocabularies — a fact worth stating plainly because the V1 spec text undercounts both.

The canonical contract: eight scope kinds#

The source of truth is MemoryScopeKeySchema in libs/contracts/src/iris/entry.ts (lines 15–45), a Zod discriminated union on kind with eight members. Each carries exactly the identifiers needed to address the scope:

kind Identifiers carried Meaning
profile userId Durable, user-controlled facts about the user.
session userId, sessionId In-flight conversation state and a short rolling window of prior sessions.
scene userId, sessionId, sceneId, roomId Spatial room/venue/scene context for embodied (V3) sessions, bounded to one rendered scene.
pose userId, sessionId, sceneId, poseId, avatarId Aggregate body-pose / avatar-alignment memory for opted-in embodied coaching.
notebook userId, notebookId Memory bound to a specific notebook, collection, or course context.
crisis userId The frame entered during a Lilith crisis cascade — see suppression below.
operator-copilot userId, operatorId A separate scope for admin copilots with narrower retention and tighter audit.
tenant tenantId, userId Institutional Metis tenant memory of learner study state; never blends into consumer profile memory.

Accuracy fix. Earlier prose (features.md and ARCHITECTURE.md:752–753) said "MemoryScope has exactly five values: profile | session | notebook | operator-copilot | tenant." That undercounts. The shipping contract is eight kinds — it also carries scene, pose, and crisis. The five-item list also contradicted the doc's own recall algorithm, which already relied on scene/crisis scopes. This page reconciles to the code.

The scene and pose scopes exist for embodied/V3 memory and were entirely absent from the V1 five-scope list. They are real (with their own retention envelopes and export payloads, below) and are simply not yet surfaced in the consumer V1 UX.

The adapter layer: eleven scopes#

The runtime adapter (libs/oshun/memory-iris/src/memory-model.ts, IrisMemoryScope) operates on a wider eleven-value vocabulary: assistant_profile, session, scene, pose, conversation, domain, cross_domain, notebook, operator_copilot, tenant, admin_review. The names differ from the canonical contract on purpose — assistant_profile is the adapter spelling of canonical profile; the adapter additionally distinguishes conversation (persistent threaded history) from session (ephemeral live context), splits domain from cross_domain, and adds the governed admin_review surface. Treat the canonical contract as the wire/storage shape and the adapter scopes as the policy-and-retention layer that maps onto it.

Per-scope governance metadata#

Each adapter scope carries a rich governance blueprint (IRIS_SCOPE_BLUEPRINTS in memory-model.ts) that the original spec never surfaced. Every scope declares:

  • requiredConsents — which consent types must be granted for the scope to be enabled (e.g. pose requires data_processing, data_storage, and sensitive_data).
  • optOutCategories — the opt-out categories that suppress the scope.
  • canonicalTiers — which memory tiers it spans (core / working / archival / episodic / semantic).
  • adminReviewable and requiresGovernanceReview — whether operators may inspect it and whether it is gated behind governance workflow.
  • allowedConsumers — the explicit allow-list of which subsystems may read it (assistant, tara, arete, veritas, nyx, nisaba, metis, studio, admin, support).

buildIrisScopePolicies() evaluates these blueprints against the user's live consents and opt-outs and returns, per scope, an enabled flag, an effective mode (durable / ephemeral / suppressed), and human-readable notes explaining any suppression ("Session memory requires data processing consent.", "Consumer support is not allowed to use this scope."). The operator_copilot, tenant, cross_domain, pose, and admin_review scopes all carry requiresGovernanceReview: true.


Retention, Decay, and Compaction#

Retention windows#

Retention is data-first: the constants live in libs/oshun/memory-iris/src/scope-hierarchy.ts, and getIrisScopeRetentionEnvelope() maps each scope to an envelope describing how long raw and summarized memory survive and what happens at expiry.

Scope Raw retention Summary retention Expiry action Constant
assistant_profile (profile) durable (no max) review retained until user/account deletion
session / conversation 30 days 90 days summarize IRIS_SESSION_RAW_RETENTION_DAYS=30, IRIS_SESSION_SUMMARY_RETENTION_DAYS=90
scene 14 days 90 days summarize IRIS_SCENE_RAW_RETENTION_DAYS=14, IRIS_SCENE_SUMMARY_RETENTION_DAYS=90
pose 7 days 30 days summarize IRIS_POSE_RAW_RETENTION_DAYS=7, IRIS_POSE_SUMMARY_RETENTION_DAYS=30
notebook bound to notebook lifetime delete (on notebook delete)
operator_copilot 14 days review IRIS_OPERATOR_COPILOT_RETENTION_DAYS=14
tenant 365 days (per contract) review IRIS_TENANT_MEMORY_RETENTION_DAYS=365

Accuracy note. The V1 spec said admin-copilot memory was "7 days raw, 30 days summarized." The shipping constant is IRIS_OPERATOR_COPILOT_RETENTION_DAYS=14 with a single review action. This page follows the code.

Session-to-profile promotion#

Session memory is never silently promoted to durable profile memory. evaluateIrisSessionProfilePromotion() enforces a two-path rule: a promotion is allowed only when the user explicitly asks ("explicit-promotion") or when a fact recurs at or above IRIS_SESSION_PROFILE_PROMOTION_THRESHOLD = 3 occurrences ("repeated-occurrence-threshold-met"). Anything else returns allowed: false with a precise reason (not-requested, implicit-promotion-blocked, repeated-occurrence-threshold-not-met). This is the implementation of the inference-vs-confirmation policy below: an inference must earn durability.

Decay function (usage-weighted, not FSRS)#

Decay is usage-weighted, computed inside the recall ranker rather than as a spaced-repetition schedule. The recencyScore in RecallPipeline (recall/pipeline.ts) is an exponential decay on the time since lastReferencedAt (falling back to updatedAt) with a 30-day half-life:

ts
const halfLifeMs = 30 * 24 * 60 * 60 * 1000;
return Math.exp(-ageMs / halfLifeMs);

Unreferenced facts therefore decay in rank; referenceCount and the origin weight push frequently used and user-stated facts back up. There is no FSRS in Iris. FSRS v4 — FSRSParameters, FSRSReviewResult, FSRS_DEFAULT_PARAMETERS, calculateRetention(stability, elapsed) — lives in libs/mnemosyne/core/src/memory-science.ts and serves study-scheduling, a different domain. The only stability/fsrs strings inside @oshun/memory-iris are unrelated (sort stability, a status literal). Do not attribute spaced repetition to Iris.

Compaction and tombstones#

Compaction is rolling summarization with traceable provenance: every entry carries a provenanceChain of ProvenanceLinks (kindsession-turn | session-summary | profile-promotion | manual-import | inference-batch | notebook-attachment | scene-event | pose-sample, each with a ref and capturedAt), so a summary can always be expanded back to the turns that produced it. Deletions are tombstonedtombstoned is the only terminal lifecycle — and propagated; no silent re-creation from old conversations, and every forget event is appended to the audit chain.


The MemoryEntry Contract#

Every fact Iris stores is a MemoryEntry. The canonical schema is MemoryEntrySchema at libs/contracts/src/iris/entry.ts:162, with type MemoryEntry = z.infer<typeof MemoryEntrySchema>. Its lifecycle is immutable-revision-then-tombstone: every mutation writes a new revision that supersedes its predecessor, and the only terminal lifecycle is tombstoned.

Fields#

Field Type Notes
id UUID Stable across revisions.
revisionId UUID Unique per revision.
previousRevisionId UUID | null Links the immutable revision chain.
userId UUID Owner.
tenantId UUID | null Set for tenant-scoped memory; enforced before scoring.
scope MemoryScopeKey The eight-kind discriminated union above.
category MemoryCategory ~28-value enum (see taxonomy).
body string, min(1).max(4000) Plain text, not a typed union.
confidence number [0,1] Derived from origin.
origin MemoryOrigin Provenance of the assertion.
lifecycle MemoryLifecycle draft | active | paused | superseded | tombstoned.
consent ConsentEntry[] Mandatory for sensitive categories.
provenanceChain ProvenanceLink[] Survives summarization.
expiresAt timestamp | null Soft-expiry.
lastReferencedAt timestamp | null Drives recency decay.
referenceCount int ≥ 0 Monotonic; resets only on user reset.
suppression MemorySuppression | null A single nullable object, not an array.
multiActor MultiActor | null A single nullable object, not an array.
auditChain AuditEntry[] Append-only.
createdAt / updatedAt timestamp

Accuracy fixes (spec text → code):

  • body is plain text. features.md–1976 described body as a discriminated union of Fact | Preference | Goal | Boundary | Relationship | Schedule | LineageDeclaration | Sensitivity. In the real schema body is z.string().min(1).max(4000) (entry.ts:170). The taxonomy that is modeled is the separate category field.
  • origin enum. features.md listed promoted-from-session. The real MemoryOriginSchema (entry.ts:81–88) is user-stated | user-confirmed | model-inferred | operator-copilot | summarized-from-session | imported. The doc's promoted-from-session is spelled summarized-from-session, and the doc omitted the real operator-copilot origin.
  • lifecycle enum. features.md listed summarized. The real MemoryLifecycleSchema (entry.ts:91–97) is draft | active | paused | superseded | tombstoned — there is no summarized lifecycle, and the doc omitted draft.
  • suppression is a single nullable object. features.md–1988 described suppression[] as an array of four reasons (crisis-frame / user-mute / tenant-policy-mute / category-revoked). The real MemorySuppressionSchema (entry.ts:124–129) is a single nullable object { reason, startedAt, endsAt, notes } whose reason enum is crisis-frame | user-pause | sensitive-category | tenant-quarantine (note the renamings).
  • multiActor is a single nullable object. features.md–1991 described multiActor[] as an array; the real MultiActorSchema (entry.ts:180) is one nullable { actorHandle, relationshipNote (max 500), sensitiveInteraction }.

Category taxonomy and the sensitive set#

MemoryCategorySchema (entry.ts:48–78) is 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.

Of these, a precise 19-member SENSITIVE_CATEGORIES ReadonlySet (entry.ts:187–211), exposed via isSensitiveCategory(), gates the sensitive path: 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, legal_jeopardy. This is far more concrete than the original prose list at features.md–1902 — note it includes the V3 embodied categories pose_alignment and biometric alongside the human-disclosure categories.

The shipping consent primitive on the entry is ConsentEntrySchema (entry.ts:100–105): { category, grantedAt, revokedAt (nullable), source: inline-prompt | settings-toggle | dsar-import }.

Staleness fix. ARCHITECTURE.md:754 described a ConsentRecord with "prior state, new state, reason code." That shape is stale. The entry-level primitive is ConsentEntry (above), and the durable, tamper-evident consent history is the separate append-only IrisConsentLedger/IrisConsentEvent described below — richer and differently shaped than "prior/new state."

Persistence and the schema test#

Every mutation writes a new immutable revision and flips the predecessor to superseded; tombstoned is the only terminal lifecycle. The contract round-trip fixture covers every origin, every lifecycle, every sensitive category, and the suppression marker.


Recall-Resolution Algorithm#

When the assistant or a domain adapter asks Iris for memory relevant to a context, Iris runs a deterministic pipeline: same context + same memory state ⇒ same ranked output. The implementation is the RecallPipeline class at libs/oshun/memory-iris/src/recall/pipeline.ts:128. Recall is never silent — each surfaced entry carries a surfaceRationale and its provenanceChain, and the whole resolution emits a RecallAuditEnvelope.

resolve({ candidates, request }) applies the gates in this order (matching the documented sequence):

  1. Tenant boundary (matchesTenantBoundary). An entry's tenant id (entry.tenantId, or scope.tenantId for tenant scopes) must equal the request's tenant; mismatches are dropped and counted under tenant-mismatch. Enforced before any scoring.
  2. Scope reachability (isReachableScope). Only entries reachable from the request scope survive. Reachability is hierarchical: session recall reaches session + profile; scene reaches scene + session + profile; pose reaches pose + scene + session + profile; notebook reaches notebook + profile (and, under tenant policy, same-tenant memory); a tenant-scoped request reaches only its own tenant+user rows. Drops are counted under scope-mismatch.
  3. Crisis gate. If request.crisisFrame === true, the candidate set collapses to safety-critical entries only — an entry survives only when request.safetyCriticalContext === true and isSafetyCriticalEntry (category === 'safety'). Everything else is dropped and counted under crisis-frame. This matches the documented behavior (features.md–2010): during a Lilith crisis frame, no normal recall.
  4. Suppression + lifecycle gate. Entries with an active suppression (its endsAt is null or in the future) are dropped and counted under their reason; entries whose lifecycle !== 'active' are dropped under lifecycle-<state>.
  5. Sensitive-category gate. Sensitive entries (sensitive category or multiActor.sensitiveInteraction) pass only when (a) the category is in the request's consentedCategories and (b) the surfacing context is in the per-category allow-list (hasRelevantSensitiveContext). The per-category context map is concrete: health/biometric/pose categories require health/safety/crisis contexts; sexuality/gender_identity require identity/safety/crisis; spiritual requires spiritual; financial requires financial/safety/crisis; legal/immigration require legal/safety/crisis; abuse/relationship-violence require relationship/safety/crisis. Drops are counted as either sensitive-category-without-consent or sensitive-category-outside-relevant-context. This is exactly the "health memory surfaces in Tara recovery but never in a Veritas briefing" guarantee from the spec.
  6. Relevance scoring. Survivors are scored by the four-factor blend in DEFAULT_WEIGHTS (pipeline.ts:113–118): recency 0.4 + semantic 0.35 + referenceCount 0.15 + origin 0.1. referenceCount is normalized as min(1, referenceCount/10); origin weight ranks user-stated (1) > user-confirmed (0.9) > summarized-from-session (0.75) > imported (0.7) > model-inferred (0.5) > operator-copilot (0.4).
  7. Conflict / freshness dedupe. dedupeByConflict collapses candidates that share a conflict key (default key = category :: actorHandle :: subject), keeping the row with the more recent updatedAt — most-recent-wins within a conflict cluster.
  8. Per-surface budget then surface. Results are capped by DEFAULT_BUDGETS, then returned with rationale and provenance. On the admin surface (outside a DSAR workflow), multiActor is redacted before the entry leaves the pipeline (redactMultiActorForRecallSurface).

Honest approximation. Both ARCHITECTURE.md:792 and features.md–2021 describe "semantic-similarity" as a ranking factor. In the code, semantic is Jaccard token overlap (jaccardSemantic) — set-intersection over tokens longer than two characters — not an embedding similarity. Neither prior doc flagged this. The pipeline accepts an injectable semantic function in RecallPipelineOptions, so a real embedding ranker can be supplied at the call site, but the shipping default is Jaccard. Treat embedding-grade semantic recall as a forward-looking seam, not a shipped capability.

Per-surface budgets#

DEFAULT_BUDGETS (pipeline.ts:45–50) caps result counts and decides whether the rationale is visible:

Surface Max entries Rationale visible
assistant 12 no (held for explainability)
shell 3 no
notebook 999 yes
admin 100 yes

These match the documented budgets at features.md–2031 (assistant ≤12, shell ≤3, notebook up to a working cap).

The recall audit envelope#

Every resolve() returns a RecallResult whose auditEnvelope is a concrete shape — far more than the spec's "suppressed entries (count only)":

ts
interface RecallAuditEnvelope {
  scopeKey: string; // e.g. "scene:user:session:scene:room"
  returnedIds: readonly string[];
  suppressionCounts: Record<string, number>; // keyed by drop reason
  evaluatedCount: number;
}

suppressionCounts is keyed by the exact reason an entry was dropped (tenant-mismatch, scope-mismatch, crisis-frame, the suppression reason, lifecycle-<state>, sensitive-category-without-consent, sensitive-category-outside-relevant-context) — counts only, never content. If a RecallAuditSink is wired, the pipeline also ingests a canonical memory.recall.resolved audit event (policyId: 'iris-memory-recall-v1') carrying the scope, returned ids, suppression counts, and evaluatedCount, with severity warning whenever anything was suppressed.


Inference vs Confirmation Policy#

Iris distinguishes user-stated facts from model-inferred ones and surfaces inferences for confirmation rather than persisting them silently.

  • Inference threshold. An inference becomes a persisted MemoryEntry only when the same inference recurs across N distinct sessions/turns above the tuned threshold (IRIS_SESSION_PROFILE_PROMOTION_THRESHOLD = 3) or the user explicitly confirms an inline prompt. Both paths start at origin = model-inferred (or summarized-from-session for compaction-derived facts).
  • Promotion. A confirmed inference promotes toward user-confirmed; uncontested inferences decay faster because their origin weight (0.5) is lower than user-stated (1.0).
  • Sensitive inferences are never auto-persisted. They always require explicit confirmation and per-category consent — enforced jointly by the sensitive-category storage gate and the recall gate.

Conflict Resolution#

When a new statement conflicts with stored memory, resolveIrisMemoryWriteConflict() (conflict-resolution.ts) chooses a rule by comparing the source kind of the prior and new assertions. The rule ladder (chooseRule) is precise:

  1. admin-copilot-never-trumps-consumer-stated — an admin_copilot_inferred write can never overwrite an explicit_user or user_confirmed fact; the existing entry is kept.
  2. explicit-user-trumps-inferred — an explicit user statement beats any inferred source.
  3. user-confirmed-trumps-inferred — a user-confirmed fact beats inferred sources, and a prior user-confirmed fact is kept against a new inferred write (forcedOutcomeForPolicy returns keep_existing).
  4. most-recent-wins — the default when both sides carry equal authority.

Meaningful conflicts surface to the user as a question ("you mentioned X earlier — should I update?") rather than a silent overwrite, and appendIrisMemoryConflictAuditEvent() logs the prior state, new state, rule applied, and user response.


Privacy-Aware Suppression#

Sensitive-category memory is the most tightly governed path in Iris.

  • Sensitive categories are the 19-member SENSITIVE_CATEGORIES set above; privacy-suppression.ts carries the parallel IRIS_PRIVACY_SENSITIVE_CATEGORIES, IRIS_PRIVACY_RECALL_CONTEXTS, and an IRIS_PRIVACY_SENSITIVE_CATEGORY_POLICIES table mapping each category to its allowed recall contexts.
  • Storage gate. evaluateIrisPrivacyStorageGate() blocks any sensitive write that lacks explicit per-category consent — sensitive facts are stored only with opt-in.
  • Recall gate. filterIrisPrivacyAwareRecall() enforces the per-category context allow-list (the same logic the recall pipeline's sensitive gate applies), so a sensitive fact never surfaces outside a relevant context and never reaches operators except through an authorized DSAR or a safety-critical exception.
  • Consent-revocation cascade. cascadeIrisSensitiveConsentRevocation() deletes the associated memory and downstream summaries when a sensitive opt-in is revoked, while retaining the audit trail.
  • PII handling. redactIrisPrivacySummary() and redactIrisPiiFromText() apply per-category redaction with tokenization (IrisPiiToken) for any cross-system reference.

Beyond the per-entry ConsentEntry, Iris keeps an append-only, fingerprinted consent ledger (consent-ledger.ts) — a real, previously undocumented governance primitive. Each IrisConsentEvent is versioned (IRIS_CONSENT_EVENT_RECORD_VERSION = 1) and carries a tamper-evident fingerprint computed by computeIrisConsentEventFingerprint() over the event body (terms hash, witness, type, status, legal basis, timestamps). validateIrisConsentEvent() recomputes and compares the fingerprint, so a rewritten event fails validation. IrisConsentLedger holds the ordered event list; appendIrisConsentEvent() is the only mutation; computeIrisActiveConsents() derives the current granted set by taking the latest event per consent type, and computeIrisConsentAuditTrail() replays the full history. Consent terms themselves are hashed with computeIrisConsentTermsHash(). The supported consent types (IRIS_CONSENT_TYPES), statuses (IRIS_CONSENT_STATUSES), legal bases (IRIS_LEGAL_BASES), and capture methods (IRIS_CONSENT_CAPTURE_METHODS) are all enumerated.


Data Rights (DSAR)#

The durable record for every member-initiated data-rights request is IrisDataRightsRequest (data-rights.ts). The three request kinds are IRIS_DATA_RIGHTS_REQUEST_KINDS = ['delete', 'export', 'access'] — concrete DSAR mechanics beyond the spec's "export / delete" bullets.

  • Delete (IrisDataRightsDeletePayload) supports mode: 'soft' | 'hard', a list of scopes/categories, a purgeConsentLedger flag (which also purges consent events for the deleted categories), and a retentionGraceMs window within which a soft delete can be re-activated. Validation enforces that a hard delete has retentionGraceMs === 0 and that at least one scope or category is named.
  • Export (IrisDataRightsExportPayload) names scopes, a format, include flags (includeConsents, includeOptOuts, includeNotebooks, includeSessionMemory, plus opt-in includeSceneMemory / includePoseMemory), a deliveryChannel, and an encrypted flag. buildIrisDataRightsMemoryExportBundle() assembles the scene/pose payloads, marking pose memory biometricAggregateOnly.
  • Access (IrisDataRightsAccessPayload) is a read-only "show me what you have on me" request that never mutates data.

The lifecycle is a validated state machine — submitted → verified → in-progress → completed | failed | cancelled | expired | appealed (ALLOWED_TRANSITIONS, isIrisDataRightsTransitionAllowed) — backed by an append-only audit trail that must begin with a submitted event and stay chronologically non-decreasing. Statutory windows are first-class: IRIS_DATA_RIGHTS_DEFAULT_DEADLINE_MS is 30 days, IRIS_DATA_RIGHTS_DEFAULT_VERIFICATION_MS is 7 days, and expireUnverifiedIrisDataRightsRequest() auto-expires a request whose verifyBy has passed. Completed delete/export requests must carry a resultFingerprint binding the 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. Verification methods (session-token, email-link, phone-otp, operator-signoff, court-order) and delivery channels are enumerated.


Customer Memory UX Flows#

The customer-facing surface lives at apps/oshun/web/src/app/profile/memory/ (page.tsx, ProfileMemoryControls.tsx, memory-state.ts, memory-controls.module.css) and in the equivalent mobile flow. Every screen is keyboard-only navigable and AA-contrast compliant. The intended flows are:

  • What we remember (index): a paginated list of MemoryEntry rows grouped by scope, with category badges, last-referenced time, an origin badge, and per-row actions (edit, forget, pause, change scope).
  • Detail view: full body, expandable provenance chain, recent recalls (where + when + why this entry surfaced), suppression state, and an audit-chain link.
  • Edit flow: per-field edit with conflict-detection preview; each edit creates a new revision and the predecessor stays visible in a history tab.
  • Forget flow: per-entry forget (tombstone), per-category clear, full-memory clear — all step-up-authed; full clear surfaces a grace window before final purge.
  • Pause flow: session-scoped "off-the-record" toggle that writes no memory, persists across reconnect, and shows a banner while active (suppression.reason = 'user-pause').
  • Scope-change flow: promote session → profile or → notebook, demote profile → notebook, demote tenant → read-only copy, restricted by per-scope rules (tenant memory cannot promote to consumer profile).
  • Export flow: the signed bundle from the DSAR path above.

Completeness note (honest). Per the V1 completeness audit, the customer memory UX is partial — the edit/pause/forget surface is not yet at full parity with the contract. The contracts, recall pipeline, consent ledger, data-rights lifecycle, and admin state machine are shipped and tested; the consumer edit-pause-forget UI is the part still in flight. See §10 of V1/TODOS.md and Privacy, Consent, Data Portability, and User Controls.


Multi-Actor Memory#

Conversations frequently reference other people. Iris treats each mention as a masked actor ref, never as a memory about that other person. The shape is the single nullable MultiActorSchema on the entry: { actorHandle, relationshipNote (max 500 chars), sensitiveInteraction }.

  • Masking. A mentioned person is referenced by an opaque actorHandle bound to the user's namespace, plus a relationship-only note ("my partner," "my professor"). The handle is hashed via @noble/hashes/sha2 (chosen over node:crypto so the module bundles for the browser — the /profile/memory surface used to 500 on the Node crypto import).
  • No third-party memory. Iris never builds a profile of the mentioned person; only the relationship context is retained.
  • Surfacing rules. Actor handles surface only inside the user's own context. On the admin recall surface and in non-DSAR inspection snapshots, multiActor is redacted to null (redactMultiActorForRecallSurface, redactMultiActorForInspection); the only exception is a DSAR where the user is the subject.
  • Right to be forgotten by association. A "forget all mentions of" request tombstones every entry tagged with the actor handle and removes the handle from the namespace.
  • Sensitive interactions. When sensitiveInteraction is true, the entry inherits the sensitive-category consent and recall gating (the recall pipeline treats it as sensitive and requires a multi-actor-sensitive consent plus a relationship/safety/crisis context).

Cross-Device Continuity Protocol#

Iris is the source of truth for hand-off between devices across reading, study, voice, ritual, journaling, and assistant transcripts. Continuity lives in two real places: the canonical ContinuationTokenSchema at libs/contracts/src/iris/continuation.ts:52 (with type ContinuationToken at line 111) and the runtime under memory-iris/src/continuity/ plus mobile-handoff.ts.

The real ContinuationToken shape#

The original spec sketched { userId, scopeKey, surfaceContext, anchorRef, posture, lastUpdatedAt }. A field-by-field check shows the real strict Zod schema is richer:

Field Type Notes
tokenId UUID (not in the sketch)
userId UUID matches
scopeKey string (≤160, no whitespace) matches
deviceId string (≤96) (not in the sketch) — needed for cross-device conflict
surfaceContext { surface, sessionId?, tenantId? } strict surfacetara | psyche | living-scene | nisaba | metis | shell
anchorRef { surface, anchorId, cursor } strict cursor is the locator (seconds into audio, fold index in a scene, page number)
posture enum reading | listening | co-watching | practicing | studying | reviewing | paused (richer than the sketch's free-text "paused at minute 4")
sensitiveCategories string[] (≤16) drives the consent guard
crossDeviceConsentId UUID | null required when sensitiveCategories is non-empty (enforced by superRefine)
idempotencyKey string idempotent write path through the BFF
lastUpdatedAt timestamp matches

A cross-field superRefine enforces two invariants: anchorRef.surface must equal surfaceContext.surface, and a non-empty sensitiveCategories requires a crossDeviceConsentId. This is the contract realization of the spec's "sensitive-category interactions emit a token only with cross-device consent" rule.

Write, read, conflict, reconnect#

Surfaces emit token updates at natural checkpoints (segment boundary, paragraph, lesson step, pause) through the BFF with idempotency-key semantics. On launch or hand-off, the shell asks Iris for the most recent N tokens and renders continuation cards. When two devices write within a tight window, the most recent write wins by server timestamp, and the loser surfaces a "this was also playing on your other device" notice instead of silently overwriting. On reconnect, the token and the durable anchorRef resume mid-segment for any surface that supports it (Tara audio, Psyche voice, Living Scenes). Tokens carry no content — only references and posture.


Operator Inspection Regime#

Every operator read of consumer memory is gated by a strict state machine, InspectionStateMachine in libs/oshun/memory-iris/src/admin-inspection/state-machine.ts. There are exactly three legitimate entry paths and two terminal states.

States and transitions#

InspectionState = requested | dsar-fulfillment | incident-escalation | routine-review | granted | denied | closed. The transition map (TRANSITIONS, lines 43–51) is an exact match to features.md–2153:

text
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)

transition() throws on any illegal transition. No back-transitions exist.

The policy gate#

At the transition to granted, defaultPolicyGate() checks role, scope-of-request, the target's tenant, target sensitivity, current consent state, and (for incidents) a signed incident reference. Concretely:

  • Cross-tenant scopes are denied (cross-tenant); a profile scope whose id ≠ the target user is scope-out-of-bounds.
  • incident-escalation without a signedIncidentRef is no-legitimate-interest.
  • For sensitive targets: a missing currentConsentSnapshotId is consent-not-current; routine-review of sensitive memory is refused; support and admin roles are refused sensitive access entirely; legal may read sensitive memory only in dsar-fulfillment; t&s may read sensitive memory only in incident-escalation with a signed incident ref. These encode the spec's authorization model: admin-copilots can never read consumer sensitive memory, the privacy/legal path is DSAR-only, and the safety path is incident-only.

User notice, audit, and replay#

When a granted → closed transition closes a sensitive read with no investigation carveout, a user notice is queued with a default 72-hour (72 * 60 * 60 * 1000 ms) compliance window (pendingUserNotices()). Every transition appends a canonical audit event to @oshun/audit-platform (action: 'iris.admin_inspection.transition', policyId: 'iris.admin_inspection.state_machine.v1') carrying actor, role, scope, target user/tenant, reason code, and — on granted — the exact set of MemoryEntry ids the operator saw. replayGrantedSnapshot() reconstructs precisely that snapshot, so any inspection is reproducible as the operator saw it (with non-DSAR multi-actor redaction applied at snapshot time). Tenant operators see only their own tenant's memory; there is no cross-tenant access.


The Assistant Shell Bridge#

Iris does not talk to the conversation runtime directly; the @oshun/shell-assistant package mediates. The shell handles intent classification and action routing across Tara, Veritas, Nyx, Arete, Nisaba, and Metis, and bridges to Iris through iris-memory-bridge.ts and to the realtime runtime through psyche-session-bridge.ts. IrisMemoryBridge (with createIrisMemoryBridge() / createAdminIrisMemoryBridge()) maintains a redaction-ready turn log that feeds export and delete, exposes recallRelevantMemory() as the assistant's recall entry point, and emits IrisMemoryBridgeEvents (including recall.completed) so exporters, audit shippers, and UI surfaces can observe what was recalled. The IrisMemoryBridgeShellMode is 'customer' | 'admin', keeping the consumer and operator recall paths distinct at the bridge layer.


Evaluation, Safety, and Tests#

Iris ships an evaluation regime that gates release:

  • Memory-leakage suite — sensitive facts in non-relevant contexts, cross-scope, cross-tenant, and cross-user leakage prompts.
  • Forget-completeness suite — deletion verified across storage, summaries, downstream uses, retrievals, and audit references (the DSAR resultFingerprint is the durable proof).
  • Conflict-resolution evals — conflict prompts with the expected resolution path (one per rule in the ladder above).
  • Decay-accuracy evals — synthetic timelines verifying the usage-weighted decay (30-day half-life), not FSRS.
  • Crisis-suppression evals — verify no normal recall during a crisis frame.
  • Determinism — same context + same memory state ⇒ same ranked output.
  • Cross-tenant leakage — every cross-tenant query rejected at the tenant-boundary gate.
  • Sensitive-category recall gating — the per-category surface allow-list enforced in every surface (assistant, shell, notebooks, ritual resume, briefing).
  • Inference threshold / promotion — synthetic timelines verifying the N-occurrence threshold (3) and the user-confirmation promotion.
  • Multi-actor — "forget all mentions of" removes every tagged entry and the actor handle.
  • Continuation token — reconnect mid-segment resumes correctly across Tara / Psyche / Living Scenes / Nisaba / Metis.
  • Admin inspection state machine — every illegitimate transition rejected; granted reads stream to audit; user notice fires on close for sensitive reads.
  • Drift detection — memory-recall accuracy, false-recall rate, sensitive-category leakage rate.