Oshun Platform · Features

Privacy, Consent, Data Portability, and User Controls

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

11sections17 minread3tables

On this page

Privacy in Oshun V1 spans consent, retention, residency, deletion, export, operator access, and disclosure across the customer, creator, tenant, and institutional contexts. It is the user-facing half of the governance area: where Review, Compliance, and Trust & Safety governs what the product does to content, this surface governs what the product may do with a person's data — and gives that person the controls to grant, withdraw, inspect, export, and erase it. The deterministic core lives in @oshun/privacy (libs/oshun/privacy/); its modules are pure-function state machines and taxonomies, with the actual enforcement (live persistence, real fan-out across domains, immutable audit storage) supplied by the runtime that composes them. The backlog for this area is §22; the feature hub is ../features.md; the architecture companions are Security, Privacy, and Compliance and Trust & Safety and Privacy.

How This Is Built — Real vs. Aspirational#

The honest boundary matters here, so it is stated up front. The @oshun/privacy library is implemented and verified as a set of deterministic taxonomies and state machines: the consent taxonomy and transition/audit logic (consent/consent.ts), the export-bundle builder with a SHA-256 integrity manifest (export-deletion/export.ts), the deletion state machine with retention constants and legal-hold gating (export-deletion/deletion.ts), the residency router with cross-region-blocked verdicts and propagation tracking (residency/residency.ts), the DSAR and operator-access workflow (dsar/dsar.ts), the privacy-center model (privacy-surface/privacy-surface.ts), and the regulatory regime / breach / audit-completeness logic (compliance/compliance.ts). These are exported through the package root @oshun/privacy (libs/oshun/privacy/src/index.ts).

What these pure cores deliberately do not do is touch storage themselves. The one intentional exception is the account-deletion fan-out: deletion-erasers/generated-artifact-eraser.ts reaches the Isis GeneratedOutput store through a lazy, injectable seam to hard-delete a subject's generated outputs (deleteMany by ownerId), returning the row count rather than fabricating a result. Everywhere else, the cores describe and validate transitions; the runtime is responsible for real classifiers, live audit-platform persistence (@oshun/audit-platform owns immutable storage), and real DSAR fan-out across domains. Consent and memory ownership are shared with Iris — see Iris Memory and Identity — and the crisis-frame cascade that privacy and safety both depend on is described in Lilith Persona Policy and Review, Compliance, and Trust & Safety. Nothing in this library reads as result-faking: the cryptographic-shred flag, for example, is recorded verbatim from the executor that performed the deletion and can never claim a shred that did not happen.

Consent (§22.1) is organized into nine consent families, enumerated in CONSENT_FAMILIES (consent/consent.ts): memory, voice, avatar, synthetic-media, notifications, privacy-surface, support, research, and educational-context. Each family carries its own purpose vocabulary so a consent is never a vague all-or-nothing toggle — it names exactly what is being permitted.

Family Real key shape / purpose enum Notes
memory MEMORY_SCOPES = profile | session | notebook; MEMORY_SENSITIVITY = baseline | sensitive Scope × sensitivity; sensitive defaults off. Owned jointly with Iris.
voice VOICE_CONSENT_PURPOSES = recording, cloning-own-voice, cloned-voice-in-personas, voice-data-analytics Each separately granular.
avatar AVATAR_CONSENT_PURPOSES = likeness-capture, generated-likeness, animation-rights, tenant-scoped-distribution Likeness rights are split from distribution rights.
synthetic-media SYNTHETIC_MEDIA_PURPOSES = ai-generation-on-uploads, derivative-works, tenant-publication Generation on user inputs vs. publishing the result.
notifications per channel (in-app, push, email, sms, voice, webhook) × domain × severityMin (info/standard/important/critical) Granular down to per-domain, per-severity.
privacy-surface PRIVACY_SURFACE_PURPOSES = research-data-sharing, analytics-granularity-coarse, analytics-granularity-fine, third-party-processor-inclusion, training-data-inclusion, cross-tenant-data-sharing The high-sensitivity surfaces (training-data inclusion, cross-tenant sharing) default off.
support SUPPORT_CONSENT_PURPOSES = agent-screen-share, agent-memory-access, session-recording Granted only for the troubleshooting window.
research per studyId Per-study granularity; never a blanket opt-in.
educational-context EDUCATIONAL_PURPOSES = assignment-data-sharing-with-teacher, assignment-data-sharing-with-institution, standards-reporting Institutional Metis-specific; see Metis — Education and Tutoring.

Each consent is keyed by a discriminated-union ConsentKey, and every key serializes to a stable, human-readable string via canonicalKeyString — for example memory:profile:sensitive, voice:cloning-own-voice, privacy-surface:training-data-inclusion, or notifications:push:tara:important. That canonical string is what the audit log, the withdrawal cascade, and the per-feature reveal all reference, so a single consent is identifiable across every surface that touches it.

Reconciling the architecture doc. Some companion text attributes consent ownership solely to Iris (@oshun/memory-iris), stating that "every consent change emits a ConsentRecord event." That is only half the picture: a full consent taxonomy and the ConsentRecord type also live here in libs/oshun/privacy/src/consent/consent.ts (CONSENT_FAMILIES, MEMORY_SCOPES, the per-family purpose enums, the transition validator). Iris owns the memory consent surface and recall; the privacy lib owns the cross-family consent core. Both are real, and they share the same record shape.

Granularity, Default State, and Withdrawal#

Consent granularity (§22.2) is enforced, not merely promised. Three rules are encoded directly in consent/consent.ts:

  • Per-feature opt-in with no bundled sensitive consent. Every key is independently toggleable. validateConsentRecord rejects any ConsentRecord whose key is sensitive (isSensitiveKey) and whose bundledWith array is non-empty, returning a bundled-sensitive-consent error. There is no "accept all" that can sweep a sensitive category into a bundle.
  • Default state derived from sensitivity. defaultState(key) returns denied for every sensitive case — sensitive memory, all of voice / avatar / synthetic-media, support, research, educational-context, and the sensitive privacy-surface purposes (research-data-sharing, analytics-granularity-fine, third-party-processor-inclusion, training-data-inclusion, cross-tenant-data-sharing). It returns granted only for baseline memory, coarse analytics, and transactional notifications. Training-data inclusion and cross-tenant sharing are off until the user explicitly opts in.
  • Audited transitions. transitionConsent is the only sanctioned way to change a consent. It computes priorState from the current record (granted / denied / unset), rejects an inconsistent prior (a mismatched user, tenant, or key produces prior-state-inconsistent), requires a non-empty reasonCode, and on success returns both the new ConsentRecord and a ConsentTransitionAudit carrying keyString, priorState, newState, atUnixSeconds, actorId, and reasonCode. Every state change therefore produces an audit entry by construction.

Withdrawal cascade#

Withdrawing a consent is not a single bit-flip; it propagates to everything derived from the prior grant. planWithdrawalCascade builds a WithdrawalJob whose steps each name a WithdrawalCascadeKind from WITHDRAWAL_CASCADE_KINDS: evict-from-memory, remove-cloned-voice-instance, remove-cloned-avatar-instance, unpublish-derivative, remove-from-training-set, remove-from-research-cohort, and unshare-with-teacher. The plan distinguishes immediate effects from propagating ones exactly as the spec requires: when memory consent is withdrawn, effectiveImmediately contains evict-from-memory (storage and recall stop at once), while the remaining downstream artifacts become tracked steps with estimatedCompletionUnixSeconds. The job's etaUnixSeconds is the max across them, so the UI can show "withdrawal effective now; derivative cleanup completes by …". Each step starts pending and advances through in-flightcompleted / failed, giving the privacy center a real job state to render rather than an unverifiable "done."

Region, Residency, and Routing#

Residency (§22.3, residency/residency.ts) keeps a region's data on that region's plane unless a sanctioned exception applies. Each launch region is a RegionDeclaration with a primaryPlaneId and an optional failoverPlaneId, and every governed resource carries a ResidencyAssignment recording its homeRegionId.

The router routeRead returns one of three ResidencyRoutingResult verdicts:

  • { route: 'primary', planeId } — the caller is in the resource's home region and the primary plane is up.
  • { route: 'failover', planeId } — the caller is home, the primary is down, and a failover plane exists.
  • { route: 'cross-region-blocked', reason: 'residency-violation' } — the caller's region differs from the resource's home region (or the home region is unknown). This is a hard block, not a degraded read: data does not leave its region just because a caller asked from elsewhere.

Cross-region movement happens only through an explicit, two-party exception. CROSS_REGION_EXCEPTION_KINDS enumerates the three lawful reasons: legal-hold, security-incident, and operator-explicit-with-consent. approveCrossRegionException enforces that the rationale is non-empty (rationale-empty), that an operator-explicit-with-consent exception carries a customerConsentRecordId (consent-required), and that the approver is not the requester (self-approval). Once approved, the move is tracked to completion: startPropagationTracker enumerates replica destinations, advanceReplicaState walks each through enqueuedin-flightcompleted/failed (illegal transitions are rejected), and evaluatePropagationCompletion reports allTerminal only when no destination is still in flight or enqueued. applyTrackerToExceptionRequest flips the request's completionTracked flag to true solely when every replica is terminal — and the doc comment notes that this flag is exactly the signal the compliance dashboard alerts on when it stays false.

Two further residency guarantees are concrete functions, not prose: residencyAwareCacheKey({ baseKey, homeRegionId }) prefixes the home region ({region}::{baseKey}) so a cache entry from one region can never be served to a caller in another, and logSegmentForRegion(regionId, logName) returns logs/{regionId}/{logName} so logs are segregated by region by construction. Subprocessor change notices are generated by buildSubprocessorChangeNotices, which diffs a prior and current subprocessor list and emits added / removed / purpose-changed SubprocessorChange entries, each carrying a customerNotificationRequiredByUnixSeconds deadline (per-tenant for institutional contracts).

Export, Deletion, and Retention#

Export bundles#

Export (§22.4, export-deletion/export.ts) covers eleven EXPORT_SCOPES: profile, conversation-history, memory, notebooks, journals, ritual-completions, claims-passages-saved, generated-artifacts, audit-visible-to-user, support-history, and billing-receipts. Three EXPORT_FORMATS are supported — json (canonical), csv (structured scopes only), and markdown (prose scopes, plus profile). defaultFormatFor picks JSON for structured scopes and Markdown for prose scopes, and isFormatValidForScope rejects, e.g., CSV for a free-text journal.

Every export ships with a tamper-evident integrity manifest. buildExportBundle canonicalizes the bundle (sorting scopes and per-entry digests) and computes a SHA-256 bundleDigest over bundle identity, scopes, format, and the entry list (via @noble/hashes), recording { version: 1, algorithm: 'sha-256', bundleDigest, perEntryDigests, producedAtUnixSeconds }. Each ExportEntry also carries its own consentContext — the granted/denied state of the consents relevant to that data — so the export is self-describing about what permissions governed the data it contains. verifyExportBundle recomputes the digest and checks the per-entry manifest (catching missing-entry, digest-mismatch, and unexpected-entry); callers must reject on mismatch before serving the bundle to the data subject. This same verification backs DSAR portability.

Deletion state machine#

Deletion (export-deletion/deletion.ts) is a strict state machine over DELETION_SCOPES (profile, the three memory.* scopes, conversation-history, notebooks, journals, generated-artifacts, full-account). A DeletionRequest moves through the states pendingsoft-deletedhard-deleted, with cancelled and blocked-by-hold as off-ramps:

  • enqueueDeletion validates the soft-delete window and checks active legal holds; if any apply, the request is created blocked-by-hold rather than pending.
  • advanceToSoftDelete requires the pending state and that the execution time has arrived (too-early otherwise), and on success sets the state to soft-deleted and issues a tombstone (tombstoneIssued: true) to prevent re-creation.
  • advanceToHardDelete requires soft-deleted, that the hard-delete deadline has passed, and that no legal hold has since attached (blocked-by-hold). It takes a CryptographicShredOutcome from the executor and records cryptographicShredApplied verbatimapplied is true only when a real shred (encryption-key destruction) was carried out and confirmed; a row-level delete without key destruction reports applied: false with reason: 'deletion-by-row-removal'. The state machine never infers the flag, so it cannot claim a shred that did not happen.
  • cancelDeletion reverses a pending or soft-deleted request.

Soft-delete window and retention constants#

The 30-day reversible window the prose describes is a real, bounded constant. SOFT_DELETE_DEFAULT_SECONDS = 30 * 86400, and validateSoftDeleteWindow clamps any tenant override between SOFT_DELETE_MIN_SECONDS = 24 * 3600 (a UX safeguard against accidental clicks) and SOFT_DELETE_MAX_SECONDS = 90 * 86400 (a regulatory cap on dangling, still-controlled data), returning below-minimum / above-maximum errors outside that band.

Per-data-class retention is the RETENTION_DAYS table, surfaced via retentionDaysFor(dataClass):

Data class Retention
raw-chat 30 days
summarized-profile durable
billing 365 * 7 days (7 years)
audit 365 * 7 days (7 years)
generated-artifact per-artifact-policy

Legal holds (LegalHold) suspend deletion and are evaluated by holdsApplicable, which matches by userId, honors hold expiry, and treats a full-account deletion as in-scope for any hold. A hold blocks both the enqueue and the hard-delete steps, with operator audit recorded on the hold itself.

Customer-Facing Privacy Surface#

The privacy center (§22.5, privacy-surface/privacy-surface.ts) is a single in-product surface. buildPrivacyCenter returns one PrivacyCenterEntry per section, each with a summary, a deepLinkPath, and an itemCount. The eight sections are exactly: consents, memory, exports, deletions, residency, operator-access-audit, subprocessors, and disclosures — so a user sees every consent, memory state, export, deletion, residency choice, subprocessor, disclosure, and an audit of operator access to their data, in one place. The deep links are concrete (/privacy/consents, /privacy/memory, /privacy/exports, /privacy/deletions, /privacy/residency, /privacy/audit, /privacy/subprocessors, /privacy/disclosures).

Two further surface guarantees are enforced in code:

  • Plain-language reveal. A FeaturePrivacyReveal answers "what this uses, where it goes, how to turn it off" and carries a readingGradeLevel. checkRevealReadingGrade rejects copy above the jurisdiction's ceiling — grade 8 for US/CA/UK/EU/BR, defaulting to 10 elsewhere — with reading-grade-too-high. Disclosure copy is also reviewed under Lilith tone policy (Lilith Persona Policy).
  • Contextual, non-buried prompts. decideContextualPrompt decides whether to surface a consent prompt at first relevant feature use. It returns shouldPrompt: false with a reason of consent-not-required-for-feature, already-granted, or recently-dismissed (re-showing only after dismissalReshowSeconds elapses), and true otherwise — so prompts appear in context, are remembered on dismissal, and are never buried in onboarding.

Admin DSAR and Operator-Access Workflow#

DSAR state machine#

Data-subject access requests (§22.6, dsar/dsar.ts) cover the six DSAR_KINDS: access, portability, rectification, erasure, restriction, and objection. A DsarRequest advances through the seven DSAR_STATES: receivedidentity-verifiedscope-determinedin-executioncompleted, with rejected and restored as terminal alternatives. The requesting party is recorded as requestingActorKind{ subject, authorized-representative, operator }.

Eligibility is checked, not assumed. checkEligibility requires identity verification (identity-not-verified) and validates the request kind against the jurisdiction: the EU and UK support all six kinds, while the US supports access / portability / erasure, and CA / BR support access / portability / rectification / erasure. An unknown jurisdiction returns jurisdiction-not-supported; an unsupported kind returns unsupported-kind-in-jurisdiction. Execution is per-data-class: determineScope records a perClassCompletion map, and recordClassCompletion marks each scope completed / unavailable and flips the request to completed only when every scope is terminal, attaching the export's integrityManifest (bundle id and digest) for portability verification.

Restore is time-windowed and elevated. checkRestoreEligibility returns tenant-forbids-restore when the tenant policy forbids it and elevation-required when no elevationApproverActorId is present — a restore never happens on a single operator's say-so.

Operator access (non-DSAR)#

Operator access to a subject's data outside a DSAR is governed by the same file. OPERATOR_ACCESS_REASONS enumerates support-investigation, safety-investigation, privacy-review, research-cohort, and legal-discovery, and SENSITIVE_OPERATOR_ACCESS flags legal-discovery and research-cohort as the categories requiring two-operator authorization. Every OperatorAccessRequest carries a reason, a time-bound window (timeBoundStartUnixSecondstimeBoundEndUnixSeconds), the data it is scoped to (scopedToData), a notificationToSubjectByUnixSeconds deadline (post-hoc user notification within the compliance window where lawful), and an auditId. authorizeOperatorAccess rejects an invalid or inverted time window (time-bound-invalid), a sensitive access lacking a second approver (two-operator-required), and a sensitive access where the approver is the requester (self-approval). This is the privacy-side counterpart to the operator surfaces described in Tenant, Institution, and Operator Toolkit and Admin Products — Web and Mobile.

Compliance, Disclosure, and Audit#

Regulatory regimes#

Compliance (§22.7, compliance/compliance.ts) maps regions to regimes rather than flattening everything into one list. REGULATORY_REGIMES enumerates GDPR, CCPA, CPRA, LGPD, PIPEDA, US-State-Privacy, FERPA, COPPA, and UK-DPA-2018, and regimesFor(region) resolves the applicable set:

Region Applicable regimes
EU GDPR
UK GDPR, UK-DPA-2018
US CCPA, CPRA, US-State-Privacy, FERPA, COPPA
CA PIPEDA
BR LGPD

Notably, the US mapping includes the education- and child-privacy regimes FERPA and COPPA — relevant to institutional Metis — which the prose feature bullet does not name. An unmapped region resolves to the empty set.

Mandatory disclosures#

MANDATORY_DISCLOSURE_KINDS enumerates the five required disclosures: synthetic-content-disclosure (per Lilith), ai-use-disclosure, third-party-processor-list, breach-notice, and retention-policy. Each MandatoryDisclosure carries perLocaleCopy; resolveDisclosure selects the active (effective, non-archived) disclosure for a region/kind and walks the BCP-47 locale hierarchy (de-CHde → first available) to find copy, returning no-active-disclosure or no-locale-copy when it cannot. requireDisclosureCoverage returns the missing (region, kind) pairs across the active launch regions — the compliance dashboard blocks a release while that list is non-empty. archiveDisclosure supersedes an old disclosure by stamping archivedAtUnixSeconds; it never deletes, so the historical record stays reconstructable.

Breach response and audit completeness#

The breach runbook is the seven-stage sequence BREACH_RESPONSE_STAGES: detectcontainassessnotifyremediatepostmortemdisclosure. advanceBreachStage enforces strictly sequential progression (illegal-transition on any skip or out-of-order timestamp). regulatoryDeadlineSecondsFromNow computes the notification clock from the region's strictest regime — 72 hours for GDPR / UK-DPA-2018 / CCPA / CPRA / LGPD / PIPEDA, 168 hours for US-State-Privacy / FERPA / COPPA, defaulting to 168 hours for an unmapped region.

Audit completeness is measured, not assumed. AUDITABLE_EVENT_KINDS lists the five event classes that must each be audited: consent-transition, operator-access, deletion, export, and cross-region-move. evaluateAuditCompleteness divides audited events by observed events per kind and returns allCovered: true only when the minimum coverage across all kinds is ≥ 1 — i.e., every consent transition, operator access, deletion, export, and cross-region move has an audit event. Immutable storage of those events is owned by @oshun/audit-platform per the architecture; this module computes the coverage signal over them.

Where Billing and Crisis Cross This Surface#

Privacy does not live alone in the governance area. Two adjacent mechanisms are worth naming because they cross this surface directly.

Entitlement single-source-of-truth. The billing core (@oshun/billing-support, libs/oshun/billing-support/) defines six ENTITLEMENT_CLASSES (free, starter, plus, pro, scholar, institutional), and billing-aje-bridge.ts collapses them onto the three canonical OshunEntitlementTier values via TIER_BY_CLASS (free → free; starter/plus → pro; pro/scholar/institutional → premium). entitlementTierForSubscription falls back to free whenever a subscription is not in an entitling state (trial/active/grace/restored), so a lapsed payment de-entitles immediately. applyPaymentSettlementToSubscription advances the subscription state machine from a settled Aje payment (confirmed/failed/refunded), which is how a confirmed crypto payment becomes an entitlement change. This is the entitlement model that gates voice/avatar generation and the institutional gradebook; details live on Support, Entitlements, Billing, and Customer Operations and Crypto Payments — Non-Custodial Entitlement Settlement.

Crisis-frame cascade. When a crisis is detected, a single activation must reach every contemplative surface. The cascade (libs/oshun/trust-safety/src/crisis/crisis-frame-cascade.ts) publishes LILITH_CRISIS_FRAME_ACTIVATED_EVENT = 'lilith.crisis_frame.activated' and fans out to CRISIS_FRAME_SURFACES = ['psyche', 'lilith-video', 'tara', 'iris', 'assistant'], carrying non-overridable haltSynthesis and suspendMemoryWrites directives. The domain stays free of @oshun/event-bus (it uses a CrisisFramePublishPort only); crisis-frame-worker.ts binds the real Redis bus and re-projects each event. This is the privacy-relevant link because a crisis frame suspends memory writes — it overrides whatever memory consent would otherwise permit. Full detail is on Review, Compliance, and Trust & Safety and Lilith Persona Policy.

Tests and Verification#

The library is covered by Vitest suites under libs/oshun/privacy/src/ (e.g. privacy.test.ts, deletion-erasers/generated-artifact-eraser.test.ts). The verification targets match the spec's test list: consent enforcement and the bundled-sensitive rejection, the withdrawal cascade plan, deletion completeness with tombstones and the verbatim shred outcome, restore authorization, residency routing and cross-region propagation completion, audit completeness, DSAR end-to-end across the seven states, the export integrity manifest round-trip, and the breach runbook stage ordering. The generated-artifact-eraser test exercises the real Isis deleteMany path against the live local DB (two of three owners' rows deleted, the third left intact), confirming that the one place the privacy engine touches domain persistence does so correctly. Cross-domain DSAR fan-out and live audit-platform persistence are the runtime's responsibility and are integration concerns beyond this pure core.